Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+24
-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
|
||||
@@ -348,6 +350,19 @@ APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||
APP_ROUTER_APPS_ENABLED=0
|
||||
APP_ROUTER_VIDEO_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 +377,18 @@ 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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,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 +35,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
|
||||
})
|
||||
@@ -88,4 +92,6 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
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(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.34",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-5
@@ -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";
|
||||
|
||||
@@ -24,7 +25,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 +55,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 +82,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)
|
||||
@@ -153,7 +155,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 };
|
||||
}
|
||||
|
||||
+17
-1
@@ -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())
|
||||
@@ -253,6 +258,7 @@ 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);
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -278,6 +284,11 @@ describe("Event types Endpoints", () => {
|
||||
value: 40,
|
||||
rolling: false,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.monthly,
|
||||
interval: 4,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -303,6 +314,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 +323,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
|
||||
eventType.offsetStart = updatedEventType.offsetStart;
|
||||
eventType.bookingWindow = updatedEventType.bookingWindow;
|
||||
eventType.recurrence = updatedEventType.recurrence;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,6 +364,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 +395,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 +421,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 () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -25,6 +26,7 @@ type InputEventTransformed = Omit<
|
||||
| "periodCountCalendarDays"
|
||||
| "periodStartDate"
|
||||
| "periodEndDate"
|
||||
| "recurrence"
|
||||
> & {
|
||||
length: number;
|
||||
slug: string;
|
||||
@@ -34,7 +36,11 @@ type InputEventTransformed = Omit<
|
||||
|
||||
@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 };
|
||||
}
|
||||
|
||||
+9
@@ -5,6 +5,7 @@ import {
|
||||
transformApiEventTypeLocations,
|
||||
transformApiEventTypeIntervalLimits,
|
||||
transformApiEventTypeFutureBookingLimits,
|
||||
transformApiEventTypeRecurrence,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@@ -25,6 +26,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
recurrence,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
@@ -38,6 +40,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
@@ -52,6 +55,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
recurrence,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
@@ -66,6 +70,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
@@ -87,4 +92,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
|
||||
return !!res ? res : {};
|
||||
}
|
||||
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
return transformApiEventTypeRecurrence(recurrence);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -14,6 +14,7 @@ import {
|
||||
parseBookingLimit,
|
||||
getResponseEventTypeIntervalLimits,
|
||||
getResponseEventTypeFutureBookingLimits,
|
||||
getResponseEventTypeRecurrence,
|
||||
} 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,
|
||||
@@ -154,7 +155,8 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
return parseRecurringEvent(recurringEvent);
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
return getResponseEventTypeRecurrence(recurringEventParsed);
|
||||
}
|
||||
|
||||
transformMetadata(metadata: any) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
+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;
|
||||
}
|
||||
+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 UpdateOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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 { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionRepository {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly organizationsMembershipsService: OrganizationsMembershipService
|
||||
) {}
|
||||
|
||||
async createOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: CreateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.dbWrite.prisma.attributeOption.create({
|
||||
data: {
|
||||
...data,
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
|
||||
try {
|
||||
const deletedAttributeOption = await this.dbWrite.prisma.attributeOption.delete({
|
||||
where: {
|
||||
id: optionId,
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
return deletedAttributeOption;
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
// P2025 is the Prisma error code for "Record to delete does not exist."
|
||||
this.logger.warn(`Attribute option not found: ${optionId}`);
|
||||
throw new NotFoundException("Attribute option not found");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
optionId: string,
|
||||
data: UpdateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.dbWrite.prisma.attributeOption.update({
|
||||
where: {
|
||||
id: optionId,
|
||||
attributeId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
|
||||
return this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
|
||||
const options = this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId,
|
||||
attributeId,
|
||||
value,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
organizationId: number;
|
||||
membershipId: number;
|
||||
attributeId: string;
|
||||
value?: string;
|
||||
attributeOptionId?: string;
|
||||
}) {
|
||||
let _attributeOptionId = attributeOptionId;
|
||||
|
||||
if (value && !attributeOptionId) {
|
||||
_attributeOptionId = await this.createDynamicAttributeOption(organizationId, attributeId, value);
|
||||
}
|
||||
|
||||
if (!_attributeOptionId) throw new Error("Attribute option not found");
|
||||
|
||||
return this.dbWrite.prisma.attributeToUser.create({
|
||||
data: {
|
||||
attributeOptionId: _attributeOptionId,
|
||||
memberId: membershipId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createDynamicAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
value: string
|
||||
): Promise<string> {
|
||||
const attributeOption = await this.createOrganizationAttributeOption(organizationId, attributeId, {
|
||||
value: value,
|
||||
slug: slugify(value),
|
||||
});
|
||||
return attributeOption.id;
|
||||
}
|
||||
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
attributeOptionId: string
|
||||
) {
|
||||
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!membership) throw new Error("Membership not found");
|
||||
|
||||
try {
|
||||
const deletedAttributeToUser = await this.dbWrite.prisma.attributeToUser.delete({
|
||||
where: { memberId_attributeOptionId: { memberId: membership.id, attributeOptionId } },
|
||||
});
|
||||
return deletedAttributeToUser;
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
// P2025 is the Prisma error code for "Record to delete does not exist."
|
||||
this.logger.warn(`Attribute option not found: ${attributeOptionId} for user ${userId}`);
|
||||
throw new NotFoundException("Attribute does not belong to this user");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
|
||||
const { options, ...attributeData } = data;
|
||||
|
||||
const attribute = await this.dbWrite.prisma.attribute.create({
|
||||
data: {
|
||||
...attributeData,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (attribute.type === "SINGLE_SELECT" || attribute.type === "MULTI_SELECT") {
|
||||
// TODO: move this to attribute option service
|
||||
await this.dbWrite.prisma.attributeOption.createMany({
|
||||
data: options.map((option) => ({
|
||||
...option,
|
||||
attributeId: attribute.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.dbRead.prisma.attribute.findUnique({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
|
||||
const attributes = await this.dbRead.prisma.attribute.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
async updateOrganizationAttribute(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: UpdateOrganizationAttributeInput
|
||||
) {
|
||||
const attribute = await this.dbWrite.prisma.attribute.update({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.dbWrite.prisma.attribute.delete({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
+9
@@ -18,6 +18,15 @@ export class OrganizationsMembershipRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
return this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
||||
return this.dbWrite.prisma.membership.delete({
|
||||
where: {
|
||||
|
||||
+6
-5
@@ -1,11 +1,12 @@
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async findOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
@@ -26,7 +27,7 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async deleteOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.delete({
|
||||
return this.dbWrite.prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
isOrganization: false,
|
||||
@@ -36,13 +37,13 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.create({
|
||||
return this.dbWrite.prisma.team.create({
|
||||
data: { ...data, parentId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.create({
|
||||
return this.dbWrite.prisma.team.create({
|
||||
data: {
|
||||
...data,
|
||||
parentId: organizationId,
|
||||
@@ -61,7 +62,7 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.update({
|
||||
return this.dbWrite.prisma.team.update({
|
||||
data: { ...data },
|
||||
where: { id: teamId, parentId: organizationId, isOrganization: false },
|
||||
});
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Webhook } from "@calcom/prisma/client";
|
||||
|
||||
type WebhookInputData = Pick<
|
||||
Webhook,
|
||||
"payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active"
|
||||
>;
|
||||
@Injectable()
|
||||
export class OrganizationsWebhooksRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async findWebhookByUrl(organizationId: number, subscriberUrl: string) {
|
||||
return this.dbRead.prisma.webhook.findFirst({
|
||||
where: { teamId: organizationId, subscriberUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async findWebhook(organizationId: number, webhookId: string) {
|
||||
return this.dbRead.prisma.webhook.findUnique({
|
||||
where: {
|
||||
id: webhookId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findWebhooks(organizationId: number) {
|
||||
return this.dbRead.prisma.webhook.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWebhook(organizationId: number, webhookId: string) {
|
||||
return this.dbRead.prisma.webhook.delete({
|
||||
where: {
|
||||
id: webhookId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createWebhook(organizationId: number, data: WebhookInputData) {
|
||||
const id = uuidv4();
|
||||
return this.dbWrite.prisma.webhook.create({
|
||||
data: { ...data, id, teamId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateWebhook(organizationId: number, webhookId: string, data: Partial<WebhookInputData>) {
|
||||
return this.dbRead.prisma.webhook.update({
|
||||
data: { ...data },
|
||||
where: { id: webhookId, teamId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async findWebhooksPaginated(organizationId: number, skip: number, take: number) {
|
||||
return this.dbRead.prisma.webhook.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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 { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionService {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionService");
|
||||
constructor(
|
||||
private readonly organizationAttributeOptionRepository: OrganizationAttributeOptionRepository,
|
||||
private readonly organizationAttributesService: OrganizationAttributesService,
|
||||
private readonly organizationsMembershipsService: OrganizationsMembershipService
|
||||
) {}
|
||||
|
||||
async createOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: CreateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.createOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
|
||||
return this.organizationAttributeOptionRepository.deleteOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
optionId
|
||||
);
|
||||
}
|
||||
|
||||
async updateOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
optionId: string,
|
||||
data: UpdateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.updateOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
optionId,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
|
||||
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptions(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
data: AssignOrganizationAttributeOptionToUserInput
|
||||
) {
|
||||
const attribute = await this.organizationAttributesService.getOrganizationAttribute(
|
||||
organizationId,
|
||||
data.attributeId
|
||||
);
|
||||
|
||||
if (!attribute) {
|
||||
throw new NotFoundException("Attribute not found");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!membership || !membership.accepted)
|
||||
throw new NotFoundException("User is not a member of the organization");
|
||||
|
||||
if (!TYPE_SUPPORTS_VALUE.has(attribute.type) && data.value) {
|
||||
throw new BadRequestException("Attribute type does not support value");
|
||||
}
|
||||
|
||||
return this.organizationAttributeOptionRepository.assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId: membership.id,
|
||||
value: data.value,
|
||||
attributeId: data.attributeId,
|
||||
attributeOptionId: data.attributeOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
attributeOptionId: string
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId,
|
||||
userId,
|
||||
attributeOptionId
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
|
||||
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptionsForUser(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributesService {
|
||||
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
|
||||
|
||||
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
|
||||
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(
|
||||
organizationId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
|
||||
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(
|
||||
organizationId,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async updateOrganizationAttribute(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: UpdateOrganizationAttributeInput
|
||||
) {
|
||||
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
SchedulingType,
|
||||
} from "@calcom/platform-types";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class InputOrganizationsEventTypesService {
|
||||
@@ -30,6 +30,7 @@ export class InputOrganizationsEventTypesService {
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookingFields,
|
||||
recurrence,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
@@ -47,6 +48,7 @@ export class InputOrganizationsEventTypesService {
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookingFields,
|
||||
recurrence,
|
||||
metadata,
|
||||
};
|
||||
|
||||
|
||||
+4
-1
@@ -47,6 +47,7 @@ export class OrganizationsEventTypesService {
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookingFields,
|
||||
recurrence,
|
||||
...rest
|
||||
} = await this.inputService.transformInputCreateTeamEventType(teamId, body);
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
@@ -69,6 +70,7 @@ export class OrganizationsEventTypesService {
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookingFields,
|
||||
recurrence,
|
||||
...rest,
|
||||
},
|
||||
user
|
||||
@@ -96,7 +98,8 @@ export class OrganizationsEventTypesService {
|
||||
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
|
||||
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
|
||||
const profileId =
|
||||
this.usersService.getUserProfileByOrgId(user, organizationId)?.id || user.movedToProfileId;
|
||||
this.usersService.getUserProfileByOrgId(user, organizationId)?.id ||
|
||||
this.usersService.getUserMainProfile(user)?.id;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
|
||||
@@ -16,6 +16,14 @@ export class OrganizationsMembershipService {
|
||||
return membership;
|
||||
}
|
||||
|
||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
|
||||
const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated(
|
||||
organizationId,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
|
||||
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsWebhooksService {
|
||||
constructor(
|
||||
private readonly organizationsWebhooksRepository: OrganizationsWebhooksRepository,
|
||||
private readonly webhooksRepository: WebhooksRepository
|
||||
) {}
|
||||
|
||||
async createWebhook(orgId: number, body: PipedInputWebhookType) {
|
||||
const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl(
|
||||
orgId,
|
||||
body.subscriberUrl
|
||||
);
|
||||
if (existingWebhook) {
|
||||
throw new ConflictException("Webhook with this subscriber url already exists for this user");
|
||||
}
|
||||
|
||||
return this.organizationsWebhooksRepository.createWebhook(orgId, {
|
||||
...body,
|
||||
payloadTemplate: body.payloadTemplate ?? null,
|
||||
secret: body.secret ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async getWebhooksPaginated(orgId: number, skip: number, take: number) {
|
||||
return this.organizationsWebhooksRepository.findWebhooksPaginated(orgId, skip, take);
|
||||
}
|
||||
|
||||
async getWebhook(webhookId: string) {
|
||||
const webhook = await this.webhooksRepository.getWebhookById(webhookId);
|
||||
if (!webhook) {
|
||||
throw new NotFoundException(`Webhook (${webhookId}) not found`);
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) {
|
||||
return this.webhooksRepository.updateWebhook(webhookId, body);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Injectable } from "@nestjs/common";
|
||||
export class SelectedCalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
|
||||
upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
|
||||
return this.dbWrite.prisma.selectedCalendar.upsert({
|
||||
create: {
|
||||
userId,
|
||||
@@ -38,6 +38,18 @@ export class SelectedCalendarsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getUserSelectedCalendar(userId: number, integration: string, externalId: string) {
|
||||
return this.dbRead.prisma.selectedCalendar.findUnique({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
userId,
|
||||
externalId,
|
||||
integration,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addUserSelectedCalendar(
|
||||
userId: number,
|
||||
integration: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user