Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1722edbd36 | ||
|
|
e68ab3b123 | ||
|
|
cf16cd3ed2 | ||
|
|
f4bc7d2ea4 | ||
|
|
f5f7242f02 | ||
|
|
8f5ac26bb1 | ||
|
|
b983adb16a | ||
|
|
8ce018995a | ||
|
|
f11086e9f8 | ||
|
|
91f5d49cd4 | ||
|
|
55e1e0fa3d | ||
|
|
4a59841b02 | ||
|
|
74a9f3c19e | ||
|
|
89250422ea | ||
|
|
2f2f4dba60 | ||
|
|
b292cafe7b | ||
|
|
82053e0c7c | ||
|
|
860d173f0a | ||
|
|
5bc0e1265d | ||
|
|
e362c37ff3 | ||
|
|
4d53f327d9 | ||
|
|
61f436be2a | ||
|
|
d1a0fc6604 | ||
|
|
976013512c | ||
|
|
d9777a3011 | ||
|
|
7725adedc8 | ||
|
|
7269b3ea60 | ||
|
|
c168df4df0 | ||
|
|
be9b0fb9a8 | ||
|
|
c056cabbbd | ||
|
|
97630e8add | ||
|
|
a5443ccfbe | ||
|
|
6f2986e42e | ||
|
|
db345de99e | ||
|
|
bde40dceb3 | ||
|
|
1a86e7c506 | ||
|
|
9ed1624111 | ||
|
|
5dff1c6df0 | ||
|
|
706d7dff42 | ||
|
|
71443ef23d | ||
|
|
17201334b6 | ||
|
|
bc82ff4d21 | ||
|
|
71ae3f157a | ||
|
|
8784be067f | ||
|
|
84a2e55125 | ||
|
|
21e23f32ac | ||
|
|
33aa9420af | ||
|
|
ef0ba48f49 | ||
|
|
6e54c459a4 | ||
|
|
f5c16f0d6b | ||
|
|
105e7d3e28 | ||
|
|
21ef1900e9 | ||
|
|
f07b73dc79 | ||
|
|
d4b0516235 | ||
|
|
7bfad11ff9 | ||
|
|
d2f7a427d0 | ||
|
|
a910eb96ad | ||
|
|
c080458faa | ||
|
|
5c75cfc17f | ||
|
|
4280f556cb | ||
|
|
21368bb130 | ||
|
|
e9076c8e3e | ||
|
|
2471ee801b | ||
|
+6 |
9fc9876b3f | ||
|
|
34ca3bd22d | ||
|
|
bebe1dd7b4 | ||
|
|
fced9a6355 | ||
|
|
6b57dd6794 | ||
|
|
eb4c58186b | ||
|
|
17e438ab9f | ||
|
|
458e6a3abf | ||
|
|
963d22648b | ||
|
|
fabcf2055b | ||
|
|
633697a812 | ||
|
|
2de33fe1bf | ||
|
|
36e79bf69c | ||
|
|
2e36cadce4 | ||
|
|
9ef532019f | ||
|
|
176dd1a7f3 | ||
|
|
e9c71abd6f | ||
|
|
eb472cba36 | ||
|
|
7d77554696 | ||
|
|
31bfa9e32e | ||
|
|
26be88fc56 | ||
|
|
86cf8feb55 | ||
|
|
1edd255846 | ||
|
|
f538877349 | ||
|
|
d759669c1e | ||
|
|
71ce87ca21 | ||
|
|
7eae004aa2 | ||
|
|
6cd427bc78 | ||
|
|
b8d67c5a41 | ||
|
|
42953548b6 | ||
|
|
28c631e108 | ||
|
|
263a96e660 | ||
|
|
d059b1399b | ||
|
|
12a9c74eb6 | ||
|
|
6bb60e36fd | ||
|
|
c1eb2512b6 | ||
|
|
55d75a9786 | ||
|
|
779eb19e7a | ||
|
|
5d8ed130de | ||
|
|
971d9ff0d8 | ||
|
|
b6bcc43655 | ||
|
|
b576fc8c26 | ||
|
|
821843bc6c | ||
|
|
d19e3e53f0 | ||
|
|
40fee7af51 | ||
|
|
f3462776e8 | ||
|
|
57e9ef7320 | ||
|
|
88f46a6b20 | ||
|
|
17f537774c | ||
|
|
3aa6878c27 | ||
|
|
ae55e5e264 | ||
|
|
36ecece181 | ||
|
|
1a0448910b | ||
|
|
ad5d645347 | ||
|
|
54f7c8328d | ||
|
|
36a1715ece | ||
|
|
edaa6b1de8 | ||
|
|
f47b2066c4 | ||
|
|
7337b3de1e | ||
|
|
933ffcbf69 | ||
|
|
cf50566a10 | ||
|
|
4424aa5b38 | ||
|
|
19634073e9 | ||
|
|
4284e86e51 | ||
|
|
f7c1d62bb8 | ||
|
|
795a228a44 | ||
|
|
0190ca6711 | ||
|
|
ee8cc81862 | ||
|
|
6b244fc5b5 | ||
|
|
356ecfe2fa | ||
|
|
bd410cf09f | ||
|
|
3c5ab5e8d2 | ||
|
|
d938e67e65 | ||
|
|
6a90518231 | ||
|
|
47e8907184 | ||
|
|
d6096f459b | ||
|
|
8839f08257 | ||
|
|
6104add6bd | ||
|
|
7aae55f417 | ||
|
|
ef1b5067c2 | ||
|
|
4caa095d52 | ||
|
|
c8e20b0c57 | ||
|
|
2fb4289798 | ||
|
|
6529b49c3c | ||
|
|
758423ecf9 | ||
|
|
ef11755488 | ||
|
|
03051a2bb1 | ||
|
|
7a57f57e99 | ||
|
|
b6d31129a3 | ||
|
|
34075ee303 | ||
|
|
89f711d929 | ||
|
|
623f0c7d93 | ||
|
|
c67877cc51 | ||
|
|
fd50728169 | ||
|
|
74567909b2 | ||
|
|
d84f08c998 | ||
|
|
685eadc30b | ||
|
|
c821f15a14 | ||
|
|
8a972a74c6 | ||
|
|
cf76cf7aae | ||
|
|
4dd762681c | ||
|
|
399a23a52f | ||
|
|
342f935aba | ||
|
|
27e4210693 | ||
|
|
66cb2bcc13 | ||
|
|
30c31d3585 | ||
|
|
e17303ea88 | ||
|
|
bebf1d80a7 | ||
|
|
5bcefb0b3e | ||
|
|
f52de7331f | ||
|
|
1690770d6a | ||
|
|
7759e6a029 |
+30
-1
@@ -334,6 +334,8 @@ 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_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
@@ -341,13 +343,28 @@ 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
|
||||
APP_ROUTER_AUTH_LOGIN_ENABLED=0
|
||||
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
|
||||
APP_ROUTER_AUTH_NEW_ENABLED=0
|
||||
APP_ROUTER_AUTH_SAML_ENABLED=0
|
||||
APP_ROUTER_AUTH_ERROR_ENABLED=0
|
||||
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
|
||||
APP_ROUTER_MORE_ENABLED=0
|
||||
APP_ROUTER_MAINTENANCE_ENABLED=0
|
||||
APP_ROUTER_UPGRADE_ENABLED=0
|
||||
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
@@ -362,10 +379,22 @@ TASKER_ENABLE_EMAILS=0
|
||||
# Ratelimiting via unkey
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
|
||||
# Used for Cal.ai Enterprise Voice AI Agents
|
||||
# https://retellai.com
|
||||
RETELL_AI_KEY=
|
||||
|
||||
# Used to disallow emails as being added as guests on bookings
|
||||
BLACKLISTED_GUEST_EMAILS=
|
||||
|
||||
# Used to allow browser push notifications
|
||||
# You can use: 'npx web-push generate-vapid-keys' to generate these keys
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report any issues with the platform
|
||||
title: ""
|
||||
labels: ["🐛 bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Actual Results
|
||||
|
||||
- What's happening right now that is different from what is expected
|
||||
|
||||
### Expected Results
|
||||
|
||||
- This is an ideal result that the system should get after the tests are performed
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
### Evidence
|
||||
|
||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Bug report
|
||||
description: "Please make sure you have checked for any duplicate issues."
|
||||
title: "(bug)"
|
||||
labels: ["🐛 bug"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
value: |
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
label: Other information
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
options:
|
||||
- label: app.cal.com
|
||||
- label: Self-hosted Cal
|
||||
- type: textarea
|
||||
id: desktop-version
|
||||
attributes:
|
||||
label: Desktop (please complete the following information)
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: [e.g. iOS]
|
||||
- **Browser**: [e.g. chrome, safari]
|
||||
- **Version**: [e.g. 22]
|
||||
value: |
|
||||
- OS:
|
||||
- Node:
|
||||
- npm:
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: nodejs-version
|
||||
attributes:
|
||||
value: |
|
||||
#### Node.JS version
|
||||
|
||||
[e.g. v18.15.0]
|
||||
- type: markdown
|
||||
id: anything-else
|
||||
attributes:
|
||||
value: |
|
||||
#### Anything else?
|
||||
|
||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||
- Anything else that you think could be an issue?
|
||||
@@ -34,6 +34,11 @@ jobs:
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-atoms:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/atoms-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Atoms production Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build atoms
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- name: Cache atoms production build
|
||||
uses: buildjet/cache@v4
|
||||
id: cache-atoms-build
|
||||
env:
|
||||
cache-name: atoms-build
|
||||
key-1: ${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[jt]sx', '!**/node_modules') }}
|
||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||
# Ensures production-build.yml will always be fresh
|
||||
key-4: ${{ github.sha }}
|
||||
with:
|
||||
path: |
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for atoms build. Skipping build."
|
||||
else
|
||||
rm -rf packages/platform/atoms/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/atoms run build
|
||||
fi
|
||||
shell: bash
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
delete:
|
||||
branches-ignore: [main, gh-pages]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# ensures that currently running Playwright workflow of deleted branch gets cancelled
|
||||
concurrency:
|
||||
group: ${{ github.event.ref }}
|
||||
@@ -20,7 +25,9 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
run: |
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1.13.0
|
||||
with:
|
||||
# upload sources
|
||||
upload_sources: true
|
||||
|
||||
# upload translations (& auto-approve 'em)
|
||||
upload_translations_args: '--auto-approve-imported'
|
||||
upload_translations: true
|
||||
push_translations: true
|
||||
|
||||
# download translations
|
||||
download_translations: true
|
||||
|
||||
# GH config
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
@@ -45,6 +45,9 @@ jobs:
|
||||
- 5432:5432
|
||||
redis:
|
||||
image: redis:latest
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Run i18n AI automation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
i18n:
|
||||
name: Run i18n
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: replexica/replexica@main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
with:
|
||||
api-key: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||
pull-request: true
|
||||
@@ -1,20 +0,0 @@
|
||||
name: PR Reviewed
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label PR as ready for E2E
|
||||
if: github.event.review.state == 'approved'
|
||||
uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
|
||||
labels: 'ready-for-e2e'
|
||||
@@ -132,6 +132,13 @@ jobs:
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-atoms:
|
||||
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/atoms-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
|
||||
@@ -16,7 +16,9 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
run: |
|
||||
@@ -65,7 +67,7 @@ jobs:
|
||||
- name: Output Report URL as Workflow Annotation
|
||||
id: url
|
||||
run: |
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/cal.com/$HTML_REPORT_URL_PATH
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
|
||||
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
||||
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
||||
- name: Find Comment
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -173,11 +173,29 @@ 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,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";
|
||||
@@ -14,6 +21,8 @@ const schemaBookingBaseBodyParams = Booking.pick({
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
status: true,
|
||||
rescheduledBy: true,
|
||||
cancelledBy: true,
|
||||
}).partial();
|
||||
|
||||
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
||||
@@ -33,6 +42,8 @@ const schemaBookingEditParams = z
|
||||
title: z.string().optional(),
|
||||
startTime: iso8601.optional(),
|
||||
endTime: iso8601.optional(),
|
||||
cancelledBy: z.string().email({ message: "Invalid Email" }).optional(),
|
||||
rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(),
|
||||
// Not supporting responses in edit as that might require re-triggering emails
|
||||
// responses
|
||||
})
|
||||
@@ -42,7 +53,23 @@ 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({
|
||||
@@ -83,9 +110,12 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
timeZone: true,
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
payment: true,
|
||||
metadata: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
fromReschedule: true,
|
||||
cancelledBy: true,
|
||||
rescheduledBy: true,
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
metadata: jsonSchema.nullable(),
|
||||
customInputs: customInputSchema.array().optional(),
|
||||
link: z.string().optional(),
|
||||
hashedLink: z.object({ link: z.string() }).optional().nullable(),
|
||||
bookingFields: eventTypeBookingFields.optional().nullable(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
@@ -30,7 +30,7 @@ enum locales {
|
||||
RO = "ro",
|
||||
NL = "nl",
|
||||
PT_BR = "pt-BR",
|
||||
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
|
||||
ES_419 = "es-419",
|
||||
KO = "ko",
|
||||
JA = "ja",
|
||||
PL = "pl",
|
||||
|
||||
@@ -30,40 +30,59 @@ async function authMiddleware(req: NextApiRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
select: {
|
||||
email: true,
|
||||
bookings: true,
|
||||
teams: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
|
||||
const filteredBookings = user?.bookings?.filter((booking) => booking.id === id);
|
||||
const userIsHost = !!filteredBookings?.length;
|
||||
|
||||
const bookingsAsAttendee = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
attendees: { some: { email: user.email } },
|
||||
},
|
||||
});
|
||||
|
||||
const bookingsAsEventTypeOwner = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
eventType: {
|
||||
owner: { id: userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
eventType: {
|
||||
team: {
|
||||
members: {
|
||||
some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
const [resultOne, resultTwo, resultThree] = await Promise.all([
|
||||
bookingsAsAttendee,
|
||||
bookingsAsEventTypeOwner,
|
||||
bookingsAsTeamOwnerOrAdmin,
|
||||
]);
|
||||
|
||||
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
|
||||
const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree];
|
||||
const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length;
|
||||
|
||||
if (!userBookingIds.includes(id)) {
|
||||
const teamBookings = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
eventType: {
|
||||
team: {
|
||||
id: {
|
||||
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamBookings) {
|
||||
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
||||
}
|
||||
}
|
||||
if (!userIsHost && !userHasTeamBookings)
|
||||
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
||||
}
|
||||
|
||||
export default authMiddleware;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,9 @@ import prisma from "@calcom/prisma";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
import checkParentEventOwnership from "../_utils/checkParentEventOwnership";
|
||||
import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /event-types/{id}:
|
||||
@@ -49,9 +52,18 @@ async function checkPermissions(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(req.query);
|
||||
if (isSystemWideAdmin) return;
|
||||
/** Only event type owners can delete it */
|
||||
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({ where: { id } });
|
||||
|
||||
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
/** Only event type owners or team owners for team events can delete it */
|
||||
if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId });
|
||||
|
||||
if (eventType.parentId) return await checkParentEventOwnership(req);
|
||||
|
||||
if (eventType.userId && eventType.userId !== userId)
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
}
|
||||
|
||||
export default defaultResponder(deleteHandler);
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
where: { id },
|
||||
include: {
|
||||
customInputs: true,
|
||||
hashedLink: { select: { link: true } },
|
||||
team: { select: { slug: true } },
|
||||
hosts: { select: { userId: true, isFixed: true } },
|
||||
owner: { select: { username: true, id: true } },
|
||||
|
||||
@@ -56,6 +56,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
},
|
||||
include: {
|
||||
customInputs: true,
|
||||
hashedLink: { select: { link: true } },
|
||||
team: { select: { slug: true } },
|
||||
hosts: { select: { userId: true, isFixed: true } },
|
||||
owner: { select: { username: true, id: true } },
|
||||
|
||||
@@ -18,12 +18,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||
/** These are already parsed upstream, we can assume they're good here. */
|
||||
const parentId = Number(body.parentId);
|
||||
const parentEventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parentId,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
where: { id: parentId },
|
||||
select: { teamId: true },
|
||||
});
|
||||
|
||||
if (!parentEventType) {
|
||||
@@ -44,7 +40,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||
where: {
|
||||
teamId: parentEventType.teamId,
|
||||
userId: userId,
|
||||
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
|
||||
role: { in: ["ADMIN", "OWNER"] },
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,21 +10,17 @@ export default async function checkTeamEventEditPermission(
|
||||
req: NextApiRequest,
|
||||
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
|
||||
) {
|
||||
const { isSystemWideAdmin } = req;
|
||||
let userId = req.userId;
|
||||
if (isSystemWideAdmin && body.userId) {
|
||||
userId = body.userId;
|
||||
}
|
||||
if (body.teamId) {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
userId: req.userId,
|
||||
teamId: body.teamId,
|
||||
accepted: true,
|
||||
role: { in: ["ADMIN", "OWNER"] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
|
||||
if (!membership) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: "No permission to operate on event-type for this team",
|
||||
|
||||
@@ -3,6 +3,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
@@ -17,14 +18,22 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
let isColdStart = true;
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { usernameList, ...rest } = req.query;
|
||||
if (isColdStart && IS_PRODUCTION) {
|
||||
console.log("Cold start of /v1/slots detected");
|
||||
isColdStart = false;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function canUserAccessTeamWithRole(
|
||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||
const team = await prisma.team.findFirst(args);
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` });
|
||||
return team;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ const generateTeamCheckoutSession = async ({
|
||||
metadata: {
|
||||
pendingPaymentTeamId,
|
||||
ownerId,
|
||||
dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Set the version of docker compose to use
|
||||
version: "3.9"
|
||||
|
||||
# The containers that compose the project
|
||||
services:
|
||||
db:
|
||||
|
||||
@@ -1,90 +1,332 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, test } from "vitest";
|
||||
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("Bookings auth middleware", () => {
|
||||
it("Returns 403 when user has no permission to the booking", async () => {
|
||||
const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
describe("Booking ownership and access in Middleware", () => {
|
||||
const adminUserId = 1;
|
||||
const ownerUserId = 2;
|
||||
const memberUserId = 3;
|
||||
const orgOwnerUserId = 4;
|
||||
const adminUserEmail = "admin@example.com";
|
||||
const ownerUserEmail = "owner@example.com";
|
||||
const memberUserEmail = "member@example.com";
|
||||
const orgOwnerUserEmail = "org-owner@example.com";
|
||||
// mock user data
|
||||
function buildMockData() {
|
||||
//Create Users
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: adminUserId,
|
||||
username: "admin",
|
||||
name: "Admin User",
|
||||
email: adminUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: ownerUserId,
|
||||
username: "owner",
|
||||
name: "Owner User",
|
||||
email: ownerUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: orgOwnerUserId,
|
||||
username: "org-owner",
|
||||
name: "Org Owner",
|
||||
email: orgOwnerUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: memberUserId,
|
||||
username: "member",
|
||||
name: "Member User",
|
||||
email: memberUserEmail,
|
||||
bookings: {
|
||||
create: {
|
||||
id: 2,
|
||||
uid: "2",
|
||||
title: "Booking 2",
|
||||
eventTypeId: 1,
|
||||
startTime: "2024-08-30T06:45:00.000Z",
|
||||
endTime: "2024-08-30T07:45:00.000Z",
|
||||
attendees: {
|
||||
create: {
|
||||
name: "Member User",
|
||||
email: memberUserEmail,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create team
|
||||
prismock.team.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: "Team 1",
|
||||
slug: "team1",
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
userId: adminUserId,
|
||||
role: MembershipRole.ADMIN,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: ownerUserId,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: memberUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create Org
|
||||
prismock.team.create({
|
||||
data: {
|
||||
id: 2,
|
||||
name: "Org",
|
||||
slug: "org",
|
||||
isOrganization: true,
|
||||
children: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
userId: orgOwnerUserId,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: memberUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: ownerUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: adminUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create eventTypes
|
||||
prismock.eventType.create({
|
||||
data: {
|
||||
id: 1,
|
||||
title: "Event 1",
|
||||
slug: "event",
|
||||
length: 60,
|
||||
bookings: {
|
||||
connect: {
|
||||
id: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
prismock.eventType.create({
|
||||
data: {
|
||||
id: 2,
|
||||
title: "Event 2",
|
||||
slug: "event",
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
bookings: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//link eventType to teams
|
||||
prismock.eventType.update({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: ownerUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
prismock.eventType.update({
|
||||
where: {
|
||||
id: 2,
|
||||
},
|
||||
data: {
|
||||
team: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//link team to org
|
||||
prismock.team.update({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
data: {
|
||||
parentId: 2,
|
||||
},
|
||||
});
|
||||
// Call Prisma to create booking with attendees
|
||||
prismock.booking.create({
|
||||
data: {
|
||||
id: 1,
|
||||
uid: "1",
|
||||
title: "Booking 1",
|
||||
userId: 1,
|
||||
startTime: "2024-08-30T06:45:00.000Z",
|
||||
endTime: "2024-08-30T07:45:00.000Z",
|
||||
eventTypeId: 2,
|
||||
attendees: {
|
||||
create: {
|
||||
name: "Admin User",
|
||||
email: adminUserEmail,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
req.userId = trialUser.id;
|
||||
|
||||
try {
|
||||
await authMiddleware(req);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
expect(httpError.statusCode).toBe(403);
|
||||
}
|
||||
test("should not throw error for bookings where user is an attendee", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = memberUserId;
|
||||
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("No error is thrown when user is the booking user", async () => {
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
test("should throw error for bookings where user is not an attendee", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = memberUserId;
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
await authMiddleware(req);
|
||||
await expect(authMiddleware(req)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("No error is thrown when user is system-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
test("should not throw error for booking where user is the event type owner", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = ownerUserId;
|
||||
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
req.userId = adminUser.id;
|
||||
test("should not throw error for booking where user is team owner or admin", async () => {
|
||||
const { req: req1 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
|
||||
req1.userId = adminUserId;
|
||||
req2.userId = ownerUserId;
|
||||
|
||||
await expect(authMiddleware(req1)).resolves.not.toThrow();
|
||||
await expect(authMiddleware(req2)).resolves.not.toThrow();
|
||||
});
|
||||
test("should throw error for booking where user is not team owner or admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
|
||||
req.userId = memberUserId;
|
||||
|
||||
await expect(authMiddleware(req)).rejects.toThrow();
|
||||
});
|
||||
test("should not throw error when user is system-wide admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = adminUserId;
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
await authMiddleware(req);
|
||||
});
|
||||
|
||||
it("No error is thrown when user is org-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
|
||||
|
||||
it("should throw error when user is org-wide admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
|
||||
req.userId = adminUser.id;
|
||||
buildMockData();
|
||||
req.userId = orgOwnerUserId;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
|
||||
await authMiddleware(req);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, beforeEach, vi } from "vitest";
|
||||
|
||||
import { buildEventType } from "@calcom/lib/test/builder";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import handler from "../../../../pages/api/event-types/[id]/_delete";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("DELETE /api/event-types/[id]", () => {
|
||||
const eventTypeId = 1234567;
|
||||
const teamId = 9999;
|
||||
const adminUser = 1111;
|
||||
const memberUser = 2222;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Mocking membership.findFirst
|
||||
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
|
||||
const { userId, teamId, accepted, role } = where;
|
||||
const mockData = [
|
||||
{ userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN },
|
||||
{ userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER },
|
||||
];
|
||||
// Return the correct user based on the query conditions
|
||||
return mockData.find(
|
||||
(membership) =>
|
||||
membership.userId === userId &&
|
||||
membership.teamId === teamId &&
|
||||
membership.accepted === accepted &&
|
||||
role.in.includes(membership.role)
|
||||
);
|
||||
});
|
||||
|
||||
// Mocking eventType.findFirst
|
||||
prismaMock.eventType.findFirst.mockResolvedValue(
|
||||
buildEventType({
|
||||
id: eventTypeId,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
|
||||
// Mocking team.findUnique
|
||||
prismaMock.team.findUnique.mockResolvedValue({
|
||||
id: teamId,
|
||||
members: [
|
||||
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
|
||||
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error", async () => {
|
||||
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "DELETE",
|
||||
body: {},
|
||||
query: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
// Assign userId to the request objects
|
||||
req.userId = memberUser;
|
||||
|
||||
await handler(req, res);
|
||||
expect(res.statusCode).toBe(403); // Check if the deletion was successful
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success", async () => {
|
||||
test("Removes event type if user is owner of team associated with event type", async () => {
|
||||
// Mocks for DELETE request
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "DELETE",
|
||||
body: {},
|
||||
query: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
// Assign userId to the request objects
|
||||
req.userId = adminUser;
|
||||
|
||||
await handler(req, res);
|
||||
expect(res.statusCode).toBe(200); // Check if the deletion was successful
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||
"test": "yarn dev:build && jest",
|
||||
@@ -19,13 +20,15 @@
|
||||
"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: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",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.36",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import appConfig from "@/config/app";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
@@ -15,7 +16,7 @@ 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 { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
@@ -32,6 +33,7 @@ import { AppController } from "./app.controller";
|
||||
BullModule.forRoot({
|
||||
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||
}),
|
||||
// Rate limiting here is handled by the CustomThrottlerGuard
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
@@ -59,7 +61,7 @@ import { AppController } from "./app.controller";
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
useClass: CustomThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingsController } from "@/ee/bookings/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";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -10,7 +11,7 @@ import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
|
||||
controllers: [BookingsController],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -21,7 +22,7 @@ import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
describe("User Authenticated", () => {
|
||||
@@ -31,6 +32,8 @@ describe("Bookings Endpoints", () => {
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let apiKeyString: string;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
@@ -56,11 +59,13 @@ describe("Bookings Endpoints", () => {
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
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",
|
||||
@@ -139,6 +144,105 @@ describe("Bookings Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to create a booking with no_available_users_found_error", async () => {
|
||||
const bookingStart = "2040-05-21T09:30:00.000Z";
|
||||
const bookingEnd = "2040-05-21T10:30:00.000Z";
|
||||
const bookingEventTypeId = eventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.expect(400)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiErrorResponse = response.body;
|
||||
expect(responseBody.error.message).toEqual("no_available_users_found_error");
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a booking with api key to get owner id", async () => {
|
||||
const bookingStart = "2040-05-22T09:30:00.000Z";
|
||||
const bookingEnd = "2040-05-22T10:30:00.000Z";
|
||||
const bookingEventTypeId = eventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
expect(responseBody.data.uid).toBeDefined();
|
||||
expect(responseBody.data.startTime).toEqual(bookingStart);
|
||||
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
|
||||
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
|
||||
expect(responseBody.data.metadata).toEqual(bookingMetadata);
|
||||
|
||||
createdBooking = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
describe("should reschedule a booking", () => {
|
||||
it("should reschedule with updated start time, end time & metadata", async () => {
|
||||
const newBookingStart = "2040-05-21T12:30:00.000Z";
|
||||
@@ -202,18 +306,22 @@ describe("Bookings Endpoints", () => {
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingsOutput = response.body;
|
||||
const fetchedBooking = responseBody.data.bookings[0];
|
||||
|
||||
expect(responseBody.data.bookings.length).toEqual(1);
|
||||
expect(responseBody.data.bookings.length).toEqual(2);
|
||||
const fetchedBooking = responseBody.data.bookings.find(
|
||||
(booking) => booking.id === createdBooking.id
|
||||
);
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(fetchedBooking).toBeDefined();
|
||||
|
||||
expect(fetchedBooking.id).toEqual(createdBooking.id);
|
||||
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
|
||||
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
|
||||
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
|
||||
expect(fetchedBooking.user?.email).toEqual(userEmail);
|
||||
if (fetchedBooking) {
|
||||
expect(fetchedBooking.id).toEqual(createdBooking.id);
|
||||
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
|
||||
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
|
||||
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
|
||||
expect(fetchedBooking.user?.email).toEqual(userEmail);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ 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 { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { API_VERSIONS_VALUES } 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";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -28,10 +30,12 @@ import {
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
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";
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
ErrorCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
@@ -86,7 +91,9 @@ export class BookingsController {
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly billingService: BillingService
|
||||
private readonly billingService: BillingService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeyRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -238,8 +245,15 @@ export class BookingsController {
|
||||
): Promise<ApiResponse<BookingResponse[]>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
try {
|
||||
const recurringEventId = uuidv4();
|
||||
for (const recurringEvent of req.body) {
|
||||
if (!recurringEvent.recurringEventId) {
|
||||
recurringEvent.recurringEventId = recurringEventId;
|
||||
}
|
||||
}
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
@@ -296,9 +310,17 @@ export class BookingsController {
|
||||
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
try {
|
||||
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (accessToken) {
|
||||
return this.oAuthFlowService.getOwnerId(accessToken);
|
||||
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);
|
||||
@@ -338,6 +360,19 @@ export class BookingsController {
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private async createNextApiRecurringBookingRequest(
|
||||
req: BookingRequest,
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
err: Error | HttpError | unknown,
|
||||
type?: "recurring" | `instant` | "no-show"
|
||||
@@ -353,6 +388,9 @@ export class BookingsController {
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||
throw new HttpException(error.message, 400);
|
||||
}
|
||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,6 @@ export class CreateBookingInput {
|
||||
@IsOptional()
|
||||
rescheduleUid?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
recurringEventId?: string;
|
||||
|
||||
@IsTimeZone()
|
||||
timeZone!: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -21,4 +21,8 @@ export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
currentRecurringIndex?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
recurringEventId?: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries";
|
||||
import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries";
|
||||
import { Credential } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
@@ -61,26 +62,70 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
let hasMatchingUsernameAndPassword = false;
|
||||
|
||||
if (existingAppleCalendarCredentials.length > 0) {
|
||||
const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find(
|
||||
(calendarCredential: Credential) => {
|
||||
const decryptedKey = JSON.parse(
|
||||
symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
if (decryptedKey.username === username) {
|
||||
if (decryptedKey.password === password) {
|
||||
hasMatchingUsernameAndPassword = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
hasCalendarWithGivenCredentials.id
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} 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";
|
||||
import {
|
||||
Injectable,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -33,7 +36,8 @@ export class CalendarsService {
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
@@ -145,4 +149,32 @@ export class CalendarsService {
|
||||
throw new NotFoundException("Calendar credentials not found");
|
||||
}
|
||||
}
|
||||
|
||||
async createAndLinkCalendarEntry(
|
||||
userId: number,
|
||||
externalId: string,
|
||||
key: Prisma.InputJsonValue,
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
credentialId
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.upsertSelectedCalendar(
|
||||
externalId,
|
||||
credential.id,
|
||||
userId,
|
||||
calendarType
|
||||
);
|
||||
}
|
||||
|
||||
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
|
||||
const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type);
|
||||
|
||||
return !credential?.invalid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +131,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
@@ -149,10 +144,39 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
primaryCal.id
|
||||
);
|
||||
|
||||
if (alreadyExistingSelectedCalendar) {
|
||||
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.credentialId ?? 0,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
// user credential probably got expired in this case
|
||||
if (!isCredentialValid) {
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.externalId,
|
||||
key as Prisma.InputJsonValue,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
alreadyExistingSelectedCalendar.credentialId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
primaryCal.id,
|
||||
key as Prisma.InputJsonValue,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,16 +168,39 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
|
||||
ownerId,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
office365OAuthCredentials,
|
||||
ownerId
|
||||
defaultCalendar.id
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
defaultCalendar.id,
|
||||
credential.id,
|
||||
if (alreadyExistingSelectedCalendar) {
|
||||
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.credentialId ?? 0,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
// user credential probably got expired in this case
|
||||
if (!isCredentialValid) {
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.externalId,
|
||||
office365OAuthCredentials,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
alreadyExistingSelectedCalendar.credentialId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
defaultCalendar.id,
|
||||
office365OAuthCredentials,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
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";
|
||||
|
||||
@@ -9,7 +10,11 @@ import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(
|
||||
userId: number,
|
||||
@@ -46,7 +51,7 @@ export class EventTypesRepository_2024_04_15 {
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
|
||||
+18
-6
@@ -6,6 +6,7 @@ import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
systemBeforeFieldEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@@ -24,7 +26,8 @@ export class EventTypesService_2024_04_15 {
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(
|
||||
@@ -53,11 +56,11 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
@@ -80,7 +83,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
@@ -125,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
|
||||
@@ -153,7 +165,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
+42
-5
@@ -20,13 +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 {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/dist/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
@@ -230,6 +230,11 @@ describe("Event types Endpoints", () => {
|
||||
value: 30,
|
||||
rolling: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -245,7 +250,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);
|
||||
@@ -253,6 +257,17 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
|
||||
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
|
||||
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
|
||||
expect(createdEventType.recurrence).toEqual(body.recurrence);
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -278,6 +293,11 @@ describe("Event types Endpoints", () => {
|
||||
value: 40,
|
||||
rolling: false,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.monthly,
|
||||
interval: 4,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -303,6 +323,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
|
||||
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
|
||||
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
|
||||
expect(updatedEventType.recurrence).toEqual(body.recurrence);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
@@ -311,6 +332,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
|
||||
eventType.offsetStart = updatedEventType.offsetStart;
|
||||
eventType.bookingWindow = updatedEventType.bookingWindow;
|
||||
eventType.recurrence = updatedEventType.recurrence;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,6 +373,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
});
|
||||
|
||||
it(`/GET/even-types by username`, async () => {
|
||||
@@ -381,6 +404,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||
@@ -406,6 +430,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
});
|
||||
|
||||
it(`/GET/:id not existing`, async () => {
|
||||
@@ -460,6 +485,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,
|
||||
@@ -529,7 +564,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",
|
||||
@@ -622,11 +657,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",
|
||||
@@ -739,7 +774,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,
|
||||
|
||||
+30
-1
@@ -24,7 +24,7 @@ import {
|
||||
Delete,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -39,12 +39,23 @@ import {
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@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) {}
|
||||
|
||||
@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,
|
||||
})
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -60,6 +71,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@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,
|
||||
})
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -91,6 +108,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@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)
|
||||
async updateEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@@ -108,6 +131,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@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,
|
||||
})
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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,
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
@@ -25,16 +26,21 @@ type InputEventTransformed = Omit<
|
||||
| "periodCountCalendarDays"
|
||||
| "periodStartDate"
|
||||
| "periodEndDate"
|
||||
| "recurrence"
|
||||
> & {
|
||||
length: number;
|
||||
slug: string;
|
||||
locations?: ReturnType<typeof transformApiEventTypeLocations>;
|
||||
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
|
||||
locations?: ReturnType<typeof transformLocationsApiToInternal>;
|
||||
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(userId: number, body: InputEventTransformed) {
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
@@ -80,7 +86,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator";
|
||||
|
||||
export enum TemplateType {
|
||||
CHECK_IN_APPOINTMENT = "CHECK_IN_APPOINTMENT",
|
||||
CUSTOM_TEMPLATE = "CUSTOM_TEMPLATE",
|
||||
}
|
||||
|
||||
export class CreatePhoneCallInput {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{1,14}$/, {
|
||||
message:
|
||||
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
|
||||
})
|
||||
@DocsProperty({ description: "Your phone number" })
|
||||
yourPhoneNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{1,14}$/, {
|
||||
message:
|
||||
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
|
||||
})
|
||||
@DocsProperty({ description: "Number to call" })
|
||||
numberToCall!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ description: "CAL API Key" })
|
||||
calApiKey!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@DocsProperty({ description: "Enabled status", default: true })
|
||||
enabled = true;
|
||||
|
||||
@IsEnum(TemplateType)
|
||||
@DocsProperty({ description: "Template type", enum: TemplateType })
|
||||
templateType: TemplateType = TemplateType.CUSTOM_TEMPLATE;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "Scheduler name" })
|
||||
schedulerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest name" })
|
||||
guestName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest email" })
|
||||
guestEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest company" })
|
||||
guestCompany?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "Begin message" })
|
||||
beginMessage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "General prompt" })
|
||||
generalPrompt?: string;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Data {
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
callId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
agentId!: string;
|
||||
}
|
||||
|
||||
export class CreatePhoneCallOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: Data,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Data)
|
||||
data!: Data;
|
||||
}
|
||||
+6
-4
@@ -46,6 +46,7 @@ export class EventTypesService_2024_06_14 {
|
||||
periodCountCalendarDays = undefined,
|
||||
periodStartDate = undefined,
|
||||
periodEndDate = undefined,
|
||||
recurrence = undefined,
|
||||
...bodyTransformed
|
||||
} = this.inputEventTypesService.transformInputCreateEventType(body);
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
@@ -68,6 +69,7 @@ export class EventTypesService_2024_06_14 {
|
||||
periodCountCalendarDays,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
recurrence,
|
||||
...bodyTransformed,
|
||||
},
|
||||
ctx: {
|
||||
@@ -119,11 +121,11 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -159,7 +161,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
@@ -270,7 +272,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
+41
-11
@@ -1,11 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
transformApiEventTypeIntervalLimits,
|
||||
transformApiEventTypeFutureBookingLimits,
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
transformIntervalLimitsApiToInternal,
|
||||
transformFutureBookingLimitsApiToInternal,
|
||||
transformRecurrenceApiToInternal,
|
||||
systemBeforeFieldName,
|
||||
systemBeforeFieldEmail,
|
||||
systemAfterFieldRescheduleReason,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { systemBeforeFieldLocation } from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -25,19 +30,22 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
recurrence,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
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),
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
@@ -52,39 +60,61 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
recurrence,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
const hasMultipleLocations = !!(locations && locations?.length > 1);
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
bookingFields: bookingFields
|
||||
? this.transformInputBookingFields(bookingFields, hasMultipleLocations)
|
||||
: undefined,
|
||||
schedule: scheduleId,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
};
|
||||
|
||||
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 : {};
|
||||
}
|
||||
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
return transformRecurrenceApiToInternal(recurrence);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -4,16 +4,17 @@ import type { EventType, User, Schedule } from "@prisma/client";
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata,
|
||||
getResponseEventTypeLocations,
|
||||
getResponseEventTypeBookingFields,
|
||||
transformLocationsInternalToApi,
|
||||
transformBookingFieldsInternalToApi,
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
SystemField,
|
||||
UserField,
|
||||
CustomField,
|
||||
parseBookingLimit,
|
||||
getResponseEventTypeIntervalLimits,
|
||||
getResponseEventTypeFutureBookingLimits,
|
||||
transformIntervalLimitsInternalToApi,
|
||||
transformFutureBookingLimitsInternalToApi,
|
||||
transformRecurrenceInternalToApi,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@@ -92,7 +93,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
const bookingFields = databaseEventType.bookingFields
|
||||
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
|
||||
: [];
|
||||
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
const users = this.transformUsers(databaseEventType.users);
|
||||
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
|
||||
@@ -114,7 +115,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
description: description || "",
|
||||
locations,
|
||||
bookingFields,
|
||||
recurringEvent,
|
||||
recurrence,
|
||||
disableGuests,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
@@ -143,18 +144,20 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
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);
|
||||
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
return transformBookingFieldsInternalToApi(bookingFields);
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
return parseRecurringEvent(recurringEvent);
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
if (!recurringEventParsed) return null;
|
||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||
}
|
||||
|
||||
transformMetadata(metadata: any) {
|
||||
@@ -180,10 +183,10 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
import { IsEnum, IsNumber, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class ProviderVerifyClientData {
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
@IsNumber()
|
||||
organizationId!: number;
|
||||
@IsString()
|
||||
name!: string;
|
||||
}
|
||||
export class ProviderVerifyClientOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
data!: ProviderVerifyClientData;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ export class CalProviderController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
clientId: oAuthClient.id,
|
||||
organizationId: oAuthClient.organizationId,
|
||||
name: oAuthClient.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -36,7 +36,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) {}
|
||||
|
||||
|
||||
+32
-2
@@ -17,7 +17,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -38,11 +38,38 @@ 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: `
|
||||
The point of creating schedules is for event types to be available at specific times.
|
||||
|
||||
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. Managed user can then change the default schedule via \`AvailabilitySettings\` atom.
|
||||
2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying
|
||||
\`"isDefault": true\` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.
|
||||
|
||||
Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.
|
||||
After creating a non default schedule you can update 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 24 hour format e.g. 08:00, 15:00 etc.
|
||||
`,
|
||||
})
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_06_11
|
||||
@@ -59,7 +86,7 @@ export class SchedulesController_2024_06_11 {
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
description: "Returns the default schedule of the authenticated user",
|
||||
type: GetDefaultScheduleOutput_2024_06_11,
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
@@ -88,6 +115,9 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({
|
||||
description: "Returns all schedules of the authenticated user",
|
||||
})
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum AuthMethods {
|
||||
"API_KEY" = "api-key",
|
||||
"ACCESS_TOKEN" = "access-token",
|
||||
"OAUTH_CLIENT" = "oauth-client",
|
||||
"NEXT_AUTH" = "next-auth",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
private logger = new Logger("CustomThrottlerGuard");
|
||||
|
||||
constructor(
|
||||
options: ThrottlerModuleOptions,
|
||||
storageService: ThrottlerStorage,
|
||||
reflector: Reflector,
|
||||
private readonly config: ConfigService
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
}
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (authorizationHeader) {
|
||||
return isApiKey(authorizationHeader, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? `api_key_${authorizationHeader}`
|
||||
: `access_token_${authorizationHeader}`;
|
||||
}
|
||||
|
||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (oauthClientId) {
|
||||
return oauthClientId;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return request.ip;
|
||||
}
|
||||
|
||||
this.logger.log(`no tracker found: ${request.url}`);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
export const GetAuthMethod = createParamDecorator<unknown, ExecutionContext>((_data, ctx) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const authMethod = request.authMethod as AuthMethods;
|
||||
|
||||
if (!authMethod) {
|
||||
throw new Error("GetAuthMethod decorator : auth method not set");
|
||||
}
|
||||
|
||||
return authMethod;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
@@ -12,13 +13,14 @@ export class OrganizationRolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private organizationsService: OrganizationsService,
|
||||
private membershipRepository: MembershipsRepository
|
||||
private membershipRepository: MembershipsRepository,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user: UserWithProfile = request.user;
|
||||
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
|
||||
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
|
||||
|
||||
if (!user || !organizationId) {
|
||||
throw new ForbiddenException("No organization associated with the user.");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
type CachedData = {
|
||||
org?: Team;
|
||||
canAccess?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IsWebhookInOrg implements CanActivate {
|
||||
constructor(
|
||||
private organizationsRepository: OrganizationsRepository,
|
||||
private organizationsWebhooksRepository: OrganizationsWebhooksRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const webhookId: string = request.params.webhookId;
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
if (!webhookId) {
|
||||
throw new ForbiddenException("No webhook id found in request params.");
|
||||
}
|
||||
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization) {
|
||||
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
|
||||
Number(organizationId),
|
||||
webhookId
|
||||
);
|
||||
if (isWebhookInOrg) canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
@@ -30,7 +31,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticate(request: Request) {
|
||||
async authenticate(request: Request & { authMethod: AuthMethods }) {
|
||||
try {
|
||||
const { params } = request;
|
||||
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
|
||||
@@ -38,11 +39,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret) {
|
||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
const requestOrigin = request.get("Origin");
|
||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? AuthMethods["API_KEY"]
|
||||
: AuthMethods["ACCESS_TOKEN"];
|
||||
return await this.authenticateBearerToken(bearerToken, requestOrigin);
|
||||
}
|
||||
|
||||
@@ -50,6 +55,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
|
||||
if (nextAuthToken) {
|
||||
request.authMethod = AuthMethods["NEXT_AUTH"];
|
||||
return await this.authenticateNextAuth(nextAuthToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
duration: 1000,
|
||||
},
|
||||
}),
|
||||
UsersModule,
|
||||
],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
|
||||
exports: [BillingService, BillingRepository],
|
||||
|
||||
@@ -9,12 +9,12 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createAppCredential(
|
||||
async upsertAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number
|
||||
userId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.getByTypeAndUserId(type, userId);
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
@@ -27,7 +27,7 @@ export class CredentialsRepository {
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credential?.id ?? 0,
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,10 @@ export class CredentialsRepository {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getUserCredentialsByIds(userId: number, credentialIds: number[]) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
@@ -60,6 +64,30 @@ export class CredentialsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserCredentialById(userId: number, credentialId: number, type: string) {
|
||||
return await this.dbRead.prisma.credential.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
id: credentialId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialsWithUserEmail = Awaited<
|
||||
|
||||
@@ -33,7 +33,7 @@ export class DeploymentsService {
|
||||
if (!licenseKey) {
|
||||
return false;
|
||||
}
|
||||
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `?key=${licenseKey}`;
|
||||
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
||||
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
||||
if (cachedData) {
|
||||
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
UsersRepository,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
],
|
||||
controllers: [DestinationCalendarsController],
|
||||
exports: [DestinationCalendarsRepository],
|
||||
|
||||
-1
@@ -79,7 +79,6 @@ export class OAuthClientUsersController {
|
||||
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
||||
);
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
console.log("asap createUser client", JSON.stringify(client, null, 2));
|
||||
|
||||
const isPlatformManaged = true;
|
||||
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
|
||||
|
||||
+5
-3
@@ -13,6 +13,7 @@ import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-gu
|
||||
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -59,7 +60,8 @@ export class OAuthClientsController {
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
private readonly teamsRepository: OrganizationsRepository,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -74,7 +76,7 @@ export class OAuthClientsController {
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: CreateOAuthClientInput
|
||||
): Promise<CreateOAuthClientResponseDto> {
|
||||
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
this.logger.log(
|
||||
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
|
||||
);
|
||||
@@ -100,7 +102,7 @@ export class OAuthClientsController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
|
||||
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
|
||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
|
||||
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
|
||||
return { status: SUCCESS_STATUS, data: clients };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
@@ -11,12 +12,12 @@ import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientGuard implements CanActivate {
|
||||
constructor(private oAuthClientRepository: OAuthClientRepository) {}
|
||||
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
|
||||
const user: GetUserReturnType = request.user;
|
||||
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
|
||||
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
|
||||
const oAuthClientId = request.params.clientId;
|
||||
|
||||
if (!oAuthClientId) {
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Options Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "member@attributes-options-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let attributeId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesOptionsCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
|
||||
// Create an attribute for testing
|
||||
attributeId = "test-attribute-id";
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should not be able to create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send({
|
||||
name: "Option 1",
|
||||
value: "option1",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to delete attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/1`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User has required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
const userEmail = "admin@attributes-options-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let attributeId: string;
|
||||
let createdOption: any;
|
||||
|
||||
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option1",
|
||||
slug: "option1",
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesOptionsCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
// Create an attribute for testing
|
||||
const attribute = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: "test-attribute",
|
||||
});
|
||||
attributeId = attribute.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send(createOptionInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdOption = response.body.data;
|
||||
expect(createdOption.value).toEqual(createOptionInput.value);
|
||||
expect(createdOption.slug).toEqual(createOptionInput.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const options = response.body.data;
|
||||
expect(options.length).toEqual(1);
|
||||
expect(options[0].value).toEqual(createOptionInput.value);
|
||||
expect(options[0].slug).toEqual(createOptionInput.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update attribute option", async () => {
|
||||
const updateOptionInput: UpdateOrganizationAttributeOptionInput = {
|
||||
value: "updated-option-value",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
|
||||
.send(updateOptionInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const updatedOption = response.body.data;
|
||||
expect(updatedOption.value).toEqual(updateOptionInput.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attributeId,
|
||||
attributeOptionId: createdOption.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const userOptions = response.body.data;
|
||||
expect(userOptions.length).toEqual(1);
|
||||
expect(userOptions[0].id).toEqual(createdOption.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should unassign attribute option from user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "member@attributes-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should not be able to create attribute for org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes`)
|
||||
.send({
|
||||
key: "department",
|
||||
value: "engineering",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to delete attribute for org", async () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/attributes/1`).expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User has required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "admin@attributes-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
let createdAttribute: any;
|
||||
|
||||
const createAttributeInput: CreateOrganizationAttributeInput = {
|
||||
name: "department",
|
||||
slug: "department",
|
||||
type: "TEXT",
|
||||
options: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create attribute for org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes`)
|
||||
.send(createAttributeInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdAttribute = response.body.data;
|
||||
expect(createdAttribute.type).toEqual(createAttributeInput.type);
|
||||
expect(createdAttribute.slug).toEqual(createAttributeInput.slug);
|
||||
expect(createdAttribute.enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get org attributes", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const attributes = response.body.data;
|
||||
expect(attributes.length).toEqual(1);
|
||||
expect(attributes[0].name).toEqual(createAttributeInput.name);
|
||||
expect(attributes[0].slug).toEqual(createAttributeInput.slug);
|
||||
expect(attributes[0].type).toEqual(createAttributeInput.type);
|
||||
expect(attributes[0].enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get single org attribute", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const attribute = response.body.data;
|
||||
expect(attribute.name).toEqual(createAttributeInput.name);
|
||||
expect(attribute.slug).toEqual(createAttributeInput.slug);
|
||||
expect(attribute.type).toEqual(createAttributeInput.type);
|
||||
expect(attribute.enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update org attribute", async () => {
|
||||
const updateAttributeInput: UpdateOrganizationAttributeInput = {
|
||||
name: "marketing",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.send(updateAttributeInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const updatedAttribute = response.body.data;
|
||||
expect(updatedAttribute.name).toEqual(updateAttributeInput.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete org attribute", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import {
|
||||
AssignOptionUserOutput,
|
||||
UnassignOptionUserOutput,
|
||||
} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output";
|
||||
import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output";
|
||||
import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output";
|
||||
import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output";
|
||||
import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output";
|
||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
export class OrganizationsOptionsAttributesController {
|
||||
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
|
||||
|
||||
// Creates an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/:attributeId/options")
|
||||
async createOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Body() bodyAttribute: CreateOrganizationAttributeOptionInput
|
||||
): Promise<CreateAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.createOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Deletes an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/:attributeId/options/:optionId")
|
||||
async deleteOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Param("optionId") optionId: string
|
||||
): Promise<DeleteAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.deleteOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
optionId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Updates an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/attributes/:attributeId/options/:optionId")
|
||||
async updateOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Param("optionId") optionId: string,
|
||||
@Body() bodyAttribute: UpdateOrganizationAttributeOptionInput
|
||||
): Promise<UpdateAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.updateOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
optionId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Gets all attribute options for an attribute
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId/options")
|
||||
async getOrganizationAttributeOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<GetAllAttributeOptionOutput> {
|
||||
const attributeOptions = await this.organizationsAttributesOptionsService.getOrganizationAttributeOptions(
|
||||
orgId,
|
||||
attributeId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Assign attribute option to user
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/options/:userId")
|
||||
async assignOrganizationAttributeOptionToUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() bodyAttribute: AssignOrganizationAttributeOptionToUserInput
|
||||
): Promise<AssignOptionUserOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.assignOrganizationAttributeOptionToUser(
|
||||
orgId,
|
||||
userId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Unassign attribute option from user
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/options/:userId/:attributeOptionId")
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("attributeOptionId") attributeOptionId: string
|
||||
): Promise<UnassignOptionUserOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.unassignOrganizationAttributeOptionFromUser(
|
||||
orgId,
|
||||
userId,
|
||||
attributeOptionId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/options/:userId")
|
||||
async getOrganizationAttributeOptionsForUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetOptionUserOutput> {
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output";
|
||||
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output";
|
||||
import {
|
||||
GetOrganizationAttributesOutput,
|
||||
GetSingleAttributeOutput,
|
||||
} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output";
|
||||
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
export class OrganizationsAttributesController {
|
||||
constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {}
|
||||
// Gets all attributes for an organization
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes")
|
||||
async getOrganizationAttributes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetOrganizationAttributesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const attributes = await this.organizationsAttributesService.getOrganizationAttributes(orgId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributes,
|
||||
};
|
||||
}
|
||||
|
||||
// Gets a single attribute for an organization
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId")
|
||||
async getOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<GetSingleAttributeOutput> {
|
||||
const attribute = await this.organizationsAttributesService.getOrganizationAttribute(orgId, attributeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Creates an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes")
|
||||
async createOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() bodyAttribute: CreateOrganizationAttributeInput
|
||||
): Promise<CreateOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.createOrganizationAttribute(
|
||||
orgId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Updates an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/attributes/:attributeId")
|
||||
async updateOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Body() bodyAttribute: UpdateOrganizationAttributeInput
|
||||
): Promise<UpdateOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.updateOrganizationAttribute(
|
||||
orgId,
|
||||
attributeId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Deletes an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/:attributeId")
|
||||
async deleteOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<DeleteOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute(
|
||||
orgId,
|
||||
attributeId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -1,3 +1,5 @@
|
||||
import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input";
|
||||
import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
@@ -91,6 +94,30 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/teams/:teamId/event-types/:eventTypeId/create-phone-call")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard)
|
||||
async createPhoneCall(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreatePhoneCallInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreatePhoneCallOutput> {
|
||||
const data = await handleCreatePhoneCall({
|
||||
user: {
|
||||
id: user.id,
|
||||
timeZone: user.timeZone,
|
||||
profile: { organization: { id: orgId } },
|
||||
},
|
||||
input: { ...body, eventTypeId },
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
async getTeamEventTypes(
|
||||
@@ -138,7 +165,7 @@ export class OrganizationsEventTypesController {
|
||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||
async updateTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
): Promise<UpdateTeamEventTypeOutput> {
|
||||
|
||||
+1
-1
@@ -17,6 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
+5
-3
@@ -1,6 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
@@ -36,7 +35,6 @@ import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Membership } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/memberships",
|
||||
@@ -89,7 +87,11 @@ export class OrganizationsMembershipsController {
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Get("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getUserSchedule(@GetMembership() membership: Membership): Promise<GetOrgMembership> {
|
||||
async getOrgMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<GetOrgMembership> {
|
||||
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { IsWebhookInOrg } from "@/modules/auth/guards/organizations/is-webhook-in-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service";
|
||||
import { CreateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import {
|
||||
TeamWebhookOutputDto as OrgWebhookOutputDto,
|
||||
TeamWebhookOutputResponseDto as OrgWebhookOutputResponseDto,
|
||||
TeamWebhooksOutputResponseDto as OrgWebhooksOutputResponseDto,
|
||||
} from "@/modules/webhooks/outputs/team-webhook.output";
|
||||
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/webhooks",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Organizations Webhooks")
|
||||
export class OrganizationsWebhooksController {
|
||||
constructor(
|
||||
private organizationsWebhooksService: OrganizationsWebhooksService,
|
||||
private webhooksService: WebhooksService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllOrganizationWebhooks(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<OrgWebhooksOutputResponseDto> {
|
||||
const { skip, take } = queryParams;
|
||||
const webhooks = await this.organizationsWebhooksService.getWebhooksPaginated(
|
||||
orgId,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: webhooks.map((webhook) =>
|
||||
plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createOrganizationWebhook(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateWebhookInputDto
|
||||
): Promise<OrgWebhookOutputResponseDto> {
|
||||
const webhook = await this.organizationsWebhooksService.createWebhook(
|
||||
orgId,
|
||||
new WebhookInputPipe().transform(body)
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsWebhookInOrg)
|
||||
@Get("/:webhookId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getOrganizationWebhook(@Param("webhookId") webhookId: string): Promise<OrgWebhookOutputResponseDto> {
|
||||
const webhook = await this.organizationsWebhooksService.getWebhook(webhookId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsWebhookInOrg)
|
||||
@Delete("/:webhookId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteWebhook(@Param("webhookId") webhookId: string): Promise<OrgWebhookOutputResponseDto> {
|
||||
const webhook = await this.webhooksService.deleteWebhook(webhookId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsWebhookInOrg)
|
||||
@Patch("/:webhookId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateOrgWebhook(
|
||||
@Param("webhookId") webhookId: string,
|
||||
@Body() body: UpdateWebhookInputDto
|
||||
): Promise<OrgWebhookOutputResponseDto> {
|
||||
const webhook = await this.organizationsWebhooksService.updateWebhook(
|
||||
webhookId,
|
||||
new PartialWebhookInputPipe().transform(body)
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import {
|
||||
TeamWebhookOutputResponseDto,
|
||||
TeamWebhooksOutputResponseDto,
|
||||
} from "@/modules/webhooks/outputs/team-webhook.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { Team, Webhook } from "@calcom/prisma/client";
|
||||
|
||||
describe("WebhooksController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
const userEmail = "webhook-controller-e2e@api.com";
|
||||
let org: Team;
|
||||
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let webhookRepositoryFixture: WebhookRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let webhook: TeamWebhookOutputResponseDto["data"];
|
||||
let otherWebhook: Webhook;
|
||||
let user: UserWithProfile;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
otherWebhook = await webhookRepositoryFixture.create({
|
||||
id: "2mdfnn2",
|
||||
subscriberUrl: "https://example.com",
|
||||
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
});
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
userRepositoryFixture.deleteByEmail(user.email);
|
||||
webhookRepositoryFixture.delete(otherWebhook.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
process.stdout.write(JSON.stringify(res.body));
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
teamId: org.id,
|
||||
},
|
||||
} satisfies TeamWebhookOutputResponseDto);
|
||||
webhook = res.body.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks (POST) should fail to create a webhook that already has same orgId / subcriberUrl combo", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (PATCH)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
|
||||
.send({
|
||||
active: false,
|
||||
} satisfies UpdateWebhookInputDto)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.data.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: false,
|
||||
payloadTemplate: "string",
|
||||
teamId: org.id,
|
||||
},
|
||||
} satisfies TeamWebhookOutputResponseDto);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (GET) should say forbidden to get a webhook that does not exist", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/webhooks/90284`).expect(403);
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/webhooks`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const responseBody = res.body as TeamWebhooksOutputResponseDto;
|
||||
responseBody.data.forEach((webhook) => {
|
||||
expect(webhook.teamId).toBe(org.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (DELETE)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: false,
|
||||
payloadTemplate: "string",
|
||||
teamId: org.id,
|
||||
},
|
||||
} satisfies TeamWebhookOutputResponseDto);
|
||||
});
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/webhooks/12993`).expect(403);
|
||||
});
|
||||
|
||||
it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class AssignOrganizationAttributeOptionToUserInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly value?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly attributeOptionId?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
readonly attributeId!: string;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly slug!: string;
|
||||
|
||||
@IsEnum(AttributeType)
|
||||
@IsNotEmpty()
|
||||
readonly type!: AttributeType;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateOrganizationAttributeOptionInput)
|
||||
readonly options!: CreateOrganizationAttributeOptionInput[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributeOptionInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly value!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly slug!: string;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateOrganizationAttributeOptionInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly value?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly slug?: string;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsEnum(AttributeType)
|
||||
@IsOptional()
|
||||
readonly type?: AttributeType;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
@@ -3,19 +3,27 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
|
||||
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
|
||||
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
|
||||
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
|
||||
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
||||
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
|
||||
@@ -24,11 +32,14 @@ import { OrganizationsSchedulesService } from "@/modules/organizations/services/
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
|
||||
import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service";
|
||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
@@ -60,6 +71,14 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesRepository,
|
||||
OrganizationsTeamsMembershipsRepository,
|
||||
OrganizationsTeamsMembershipsService,
|
||||
OrganizationAttributesService,
|
||||
OrganizationAttributeOptionService,
|
||||
OrganizationAttributeOptionRepository,
|
||||
OrganizationAttributesRepository,
|
||||
OrganizationsWebhooksRepository,
|
||||
OrganizationsWebhooksService,
|
||||
WebhooksRepository,
|
||||
WebhooksService,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -71,6 +90,14 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsTeamsMembershipsRepository,
|
||||
OrganizationsTeamsMembershipsService,
|
||||
OrganizationAttributesService,
|
||||
OrganizationAttributeOptionService,
|
||||
OrganizationAttributeOptionRepository,
|
||||
OrganizationAttributesRepository,
|
||||
OrganizationsWebhooksRepository,
|
||||
OrganizationsWebhooksService,
|
||||
WebhooksRepository,
|
||||
WebhooksService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -79,6 +106,9 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipsController,
|
||||
OrganizationsEventTypesController,
|
||||
OrganizationsTeamsMembershipsController,
|
||||
OrganizationsAttributesController,
|
||||
OrganizationsOptionsAttributesController,
|
||||
OrganizationsWebhooksController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
|
||||
export const AttributeType = {
|
||||
TEXT: "TEXT",
|
||||
NUMBER: "NUMBER",
|
||||
SINGLE_SELECT: "SINGLE_SELECT",
|
||||
MULTI_SELECT: "MULTI_SELECT",
|
||||
} as const;
|
||||
|
||||
export type AttributeType = (typeof AttributeType)[keyof typeof AttributeType];
|
||||
|
||||
export class Attribute {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_123" })
|
||||
id!: string;
|
||||
|
||||
@IsInt()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: true,
|
||||
description: "The team ID associated with the attribute",
|
||||
example: 1,
|
||||
})
|
||||
teamId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The type of the attribute",
|
||||
enum: AttributeType,
|
||||
})
|
||||
@IsEnum(AttributeType)
|
||||
type!: AttributeType;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The name of the attribute",
|
||||
example: "Attribute Name",
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The slug of the attribute",
|
||||
example: "attribute-name",
|
||||
})
|
||||
slug!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
description: "Whether the attribute is enabled and displayed on their profile",
|
||||
example: true,
|
||||
})
|
||||
enabled!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: false,
|
||||
description: "Whether users can edit the relation",
|
||||
example: true,
|
||||
})
|
||||
usersCanEditRelation!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class BaseOutputDTO {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class DeleteOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsString, IsEnum, ValidateNested, IsOptional } from "class-validator";
|
||||
|
||||
import { AttributeType } from "@calcom/prisma/client";
|
||||
|
||||
export class GetSingleAttributeOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@IsOptional()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute | null;
|
||||
}
|
||||
|
||||
export class GetOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class AssignOptionUserOutputData {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: Number, required: true, description: "The ID form the org membership for the user" })
|
||||
memberId!: number;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The value of the option" })
|
||||
attributeOptionId!: string;
|
||||
}
|
||||
|
||||
export class AssignOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => AssignOptionUserOutputData)
|
||||
data!: AssignOptionUserOutputData;
|
||||
}
|
||||
|
||||
export class UnassignOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => AssignOptionUserOutputData)
|
||||
data!: AssignOptionUserOutputData;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class DeleteAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class GetOptionUserOutputData {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute" })
|
||||
attributeId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The value of the option" })
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The slug of the option" })
|
||||
slug!: string;
|
||||
}
|
||||
|
||||
export class GetOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => GetOptionUserOutputData)
|
||||
data!: GetOptionUserOutputData[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class GetAllAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput[];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
|
||||
export class OptionOutput {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The ID of the option",
|
||||
example: "attr_option_id",
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
||||
attributeId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The value of the option",
|
||||
example: "option_value",
|
||||
})
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The slug of the option",
|
||||
example: "option-slug",
|
||||
})
|
||||
slug!: string;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class UpdateAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user