Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2701b4de79 | ||
|
|
a9f83ac5e2 | ||
|
|
ff1533d344 | ||
|
|
d9cddd85ff | ||
|
|
1ab4b9dfab | ||
|
|
f3d0475840 | ||
|
|
061283bab9 | ||
|
|
4e0798577a | ||
|
|
c95b0833c1 | ||
|
|
2a7c6590a3 | ||
|
|
7d2a06ddb6 | ||
|
|
d34ad2b0f6 | ||
|
|
e06249d2df | ||
|
|
a09dfe37a1 | ||
|
|
71e7f16b29 | ||
|
|
d6546c3107 | ||
|
|
625c260e40 | ||
|
|
81224f324a | ||
|
|
700d8e7f30 | ||
|
|
301376e33b | ||
|
|
57476ea392 | ||
|
|
9c2d7f99c2 | ||
|
|
ef3281f06f | ||
|
|
d03f45dd0f | ||
|
|
2b2bf36703 | ||
|
|
272d97c0a3 | ||
|
|
f2b2b6e491 | ||
|
|
15ef547e41 | ||
|
|
251d29f65b | ||
|
|
8b652613dd | ||
|
|
8e1a0999d2 | ||
|
|
e29aa2097e | ||
|
|
1578dee622 | ||
|
|
e5ebf7feb4 | ||
|
|
06348982a3 | ||
|
|
a7d87fc5be | ||
|
|
0d77fc8474 | ||
|
|
0af77be127 | ||
|
|
afbfa11ab5 | ||
|
|
a7aabc8b4f | ||
|
|
c941192979 | ||
|
|
44311f51ba | ||
|
|
0af6354954 | ||
|
|
dd7c553cc3 | ||
|
|
5cb1d4aad0 | ||
|
|
701490a493 | ||
|
|
6499243a42 | ||
|
|
3c8215add3 | ||
|
|
2bd7549f20 | ||
|
|
629371a5f1 | ||
|
|
dc3df122e2 | ||
|
|
176fb777fc | ||
|
|
ae7fd0cae2 | ||
|
|
d1184f65e2 | ||
|
|
342e5baca2 | ||
|
|
fbfa27735b | ||
|
|
37c26793f5 | ||
|
|
956f81fab3 | ||
|
|
1dcd54aab7 | ||
|
|
68d3d9d964 | ||
|
|
717a26f223 | ||
|
|
6818806c5e | ||
|
|
dd7f108f08 | ||
|
|
7e4d9e26c6 | ||
|
|
a7352cf587 | ||
|
|
5f8db0557c | ||
|
|
88be45457f | ||
|
|
90a42c37fd | ||
|
|
cf7430d531 | ||
|
|
105a82f97d | ||
|
|
fdd3d11111 | ||
|
|
e7ea1884b7 | ||
|
|
c04402e7ee | ||
|
|
075d209b6f | ||
|
|
26e8582302 | ||
|
|
9f277c5179 | ||
|
|
21411b8f93 | ||
|
|
0c160de404 | ||
|
|
a0eca13c18 | ||
|
|
5cda123a4c | ||
|
|
6169b92e98 | ||
|
|
cc1efe1298 | ||
|
|
7635d338c0 | ||
|
|
03ebabd6b4 | ||
|
|
0ab1bd0b6c | ||
|
|
908e5e05f2 | ||
|
|
3490272815 | ||
|
|
02dab51543 | ||
|
|
358b873aa7 | ||
|
|
44aa26292d | ||
|
|
b8530e2d78 | ||
|
|
7f1b94ad19 | ||
|
|
801ba50221 | ||
|
|
74af23136c | ||
|
|
da9ee5c186 | ||
|
|
037cb8ee2a | ||
|
|
f0e10129a1 | ||
|
|
7c5731e893 | ||
|
|
1012f2f153 | ||
|
|
d6f6be7d50 | ||
|
|
9e10dfd77b | ||
|
|
82782fcf77 | ||
|
|
2477220d2d | ||
|
|
89fe869766 | ||
|
|
1f102bf3b4 | ||
|
|
7a0b8a9eba | ||
|
|
8043505f3a | ||
|
|
919fc3cdd5 | ||
|
|
d7e6334ccb | ||
|
|
06e5c9803a | ||
|
|
528b27f31e | ||
|
|
4282cd221c | ||
|
|
38738c1a3a | ||
|
|
b5dcc7a991 | ||
|
|
5ceda11a0b | ||
|
|
787828d0ca | ||
|
|
89230474a5 | ||
|
|
d7fc11df5e | ||
|
|
1ff5d57ded | ||
|
|
19641ffc05 | ||
|
|
ea80e8c8d4 | ||
|
|
cba0fa8551 | ||
|
|
530cb94f0b | ||
|
|
d4ef6e83cf | ||
|
|
cddd8d7d9d | ||
|
|
1d6959c4ac | ||
|
|
a3fc17bc75 | ||
|
|
ad4b5957fc | ||
|
|
a7cb71726a | ||
|
|
3e7a848769 | ||
|
|
e6e67848b3 | ||
|
|
bf42cd4de9 | ||
|
|
6af7f8b0db | ||
|
|
421d03eb1a | ||
|
|
9d39742802 | ||
|
|
5267f8c3c1 | ||
|
|
c6ec42e735 | ||
|
|
1325e8488f | ||
|
|
d50a7b9d00 | ||
|
|
452729f4ab | ||
|
|
0206b364ee | ||
|
|
86353191d2 | ||
|
|
38f96d97d3 | ||
|
|
e6d44ce620 | ||
|
|
9e1e50e90b | ||
|
|
a73b804d48 | ||
|
|
4407a1952c | ||
|
|
dc3742d322 | ||
|
|
8b4f675cee | ||
|
|
53a931da4e | ||
|
|
228fbaf72e | ||
|
|
0c53717d0f | ||
|
|
e239b67171 | ||
|
|
fd390f9d43 | ||
|
|
eae779b81c | ||
|
|
6d621e0b60 | ||
|
|
ed3de528f3 | ||
|
|
92f1baa320 | ||
|
|
984cd64083 | ||
|
|
db8c7942a9 | ||
|
|
4e5d4f67d5 | ||
|
|
83488f0a35 | ||
|
|
9a52938f64 | ||
|
|
e24e1d8769 | ||
|
|
ef4be48ea6 | ||
|
|
5c9ad93923 | ||
|
|
85fe40688e | ||
|
|
829edec0ea | ||
|
|
81a99ea0b2 | ||
|
|
799e354059 | ||
|
|
584c991172 | ||
|
|
47e976df96 | ||
|
|
b44efddc0f | ||
|
|
bc665cbeab | ||
|
|
02b1393ff4 | ||
|
|
52e27bab53 | ||
|
|
45278a2195 | ||
|
|
2e8b89c4ee | ||
|
|
7d17b443cf | ||
|
|
c6ab6b3d13 | ||
|
|
c741d8133e | ||
|
|
b01cb27c56 | ||
|
|
c48f2043f2 | ||
|
|
630eee4fab | ||
|
|
1ebf553181 | ||
|
|
e8a315ac4e | ||
|
|
c196ff1095 | ||
|
|
bf0fbe4888 | ||
|
|
eb6418c0d5 | ||
|
|
2df2868b20 | ||
|
|
7f48c7fdad | ||
|
|
2101051623 | ||
|
|
a241cb0bfc | ||
|
|
5417012cec | ||
|
|
8e818e0567 | ||
|
|
1fdd04215d | ||
|
|
0bf0749b29 | ||
|
|
4006143e3a | ||
|
|
c6ad767565 | ||
|
|
896f8dbd3d | ||
|
|
f1db27c0ad | ||
|
|
3a1e04bb42 | ||
|
|
ab37847ea1 | ||
|
|
0a2ddf05ca | ||
|
|
04fc0f1d52 | ||
|
|
b76c4c9dcc | ||
|
|
68b71bfb0a | ||
|
|
763c568797 | ||
|
|
4659d08c07 | ||
|
|
b9c350cc45 | ||
|
|
f63d705523 | ||
|
|
2907c5e696 | ||
|
|
dcb47e31ed | ||
|
|
cd5b75fe2c | ||
|
|
97bdf837a9 | ||
|
|
5663b2ae59 | ||
|
|
02b57eb82e | ||
|
|
b23dfa194e | ||
|
|
2484b6fad2 | ||
|
|
c154bb31b7 | ||
|
|
0cf4ebd544 | ||
|
|
805c4d4c09 | ||
|
|
99330b3117 | ||
|
|
cc90a62090 | ||
|
|
79bcd6dc30 | ||
|
|
c6daa61e5e | ||
|
|
b693b6898a | ||
|
|
fb55bc2dcd | ||
|
|
71bd140fe4 | ||
|
|
4c5608147c | ||
|
|
7d16da5c98 | ||
|
|
0ae6e92558 | ||
|
|
052a38e39b |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@calcom/atoms": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@calcom/atoms": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@calcom/atoms": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
|
||||||
+8
-1
@@ -214,6 +214,8 @@ STRIPE_TEAM_PRODUCT_ID=
|
|||||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
STRIPE_ORG_PRODUCT_ID=
|
STRIPE_ORG_PRODUCT_ID=
|
||||||
|
# Used to pass trial days for orgs during the Stripe checkout session
|
||||||
|
STRIPE_ORG_TRIAL_DAYS=
|
||||||
|
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_WEBHOOK_SECRET_APPS=
|
STRIPE_WEBHOOK_SECRET_APPS=
|
||||||
@@ -423,7 +425,8 @@ VAPID_PRIVATE_KEY=
|
|||||||
|
|
||||||
# Mintlify chat api
|
# Mintlify chat api
|
||||||
# Power AI chat in for docs
|
# Power AI chat in for docs
|
||||||
NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY=
|
MINTLIFY_CHAT_API_KEY=
|
||||||
|
NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT=
|
||||||
NEXT_PUBLIC_CHAT_API_URL=
|
NEXT_PUBLIC_CHAT_API_URL=
|
||||||
NEXT_PUBLIC_DOCS_URL=
|
NEXT_PUBLIC_DOCS_URL=
|
||||||
|
|
||||||
@@ -489,3 +492,7 @@ SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
|||||||
|
|
||||||
# Force Node.js to use UTC as process timezone
|
# Force Node.js to use UTC as process timezone
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
|
# test oauth client for `packages/platform/examples/base` (optional)
|
||||||
|
SEED_PLATFORM_OAUTH_CLIENT_ID=
|
||||||
|
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/API
|
/apps/api/**/* @calcom/API
|
||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
|
||||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/lib/csp.ts @calcom/Foundation
|
/apps/web/lib/csp.ts @calcom/Foundation
|
||||||
/apps/web/lib/buildNonce.ts @calcom/Foundation
|
/apps/web/lib/buildNonce.ts @calcom/Foundation
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ runs:
|
|||||||
with:
|
with:
|
||||||
path: ${{ inputs.path }}
|
path: ${{ inputs.path }}
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||||
- run: USE_POOL=true yarn db-seed
|
- run: yarn db-seed
|
||||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Postgres Dump Backup
|
- name: Postgres Dump Backup
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
|
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Checkout GitHub Pages Branch
|
- name: Checkout GitHub Pages Branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: calcom/test-results
|
repository: calcom/test-results-2
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Checkout GitHub Pages Branch
|
- name: Checkout GitHub Pages Branch
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
repository: calcom/test-results
|
repository: calcom/test-results-2
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- name: Set Git User
|
- name: Set Git User
|
||||||
|
|||||||
@@ -72,18 +72,22 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
|
|
||||||
- name: Generate Swagger
|
- name: Generate Swagger
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
run: yarn generate-swagger
|
run: yarn generate-swagger
|
||||||
|
|
||||||
- name: Check breaking changes
|
- name: Check breaking changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@main
|
run: |
|
||||||
with:
|
docker run --rm \
|
||||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
-v "$PWD:/work" -w /work \
|
||||||
revision: docs/api-reference/v2/openapi.json
|
tufin/oasdiff:latest \
|
||||||
fail-on: ERR
|
breaking \
|
||||||
|
https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json \
|
||||||
|
docs/api-reference/v2/openapi.json \
|
||||||
|
--fail-on ERR \
|
||||||
|
--err-ignore .github/oasdiff-err-ignore.txt
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
run: |
|
run: |
|
||||||
@@ -91,7 +95,7 @@ jobs:
|
|||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
INTEGRATION_TESTS: "true"
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Checkout GitHub Pages Branch
|
- name: Checkout GitHub Pages Branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: calcom/test-results
|
repository: calcom/test-results-2
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- name: Set Git User
|
- name: Set Git User
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
- name: Output Report URL as Workflow Annotation
|
- name: Output Report URL as Workflow Annotation
|
||||||
id: url
|
id: url
|
||||||
run: |
|
run: |
|
||||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
|
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results-2/$HTML_REPORT_URL_PATH
|
||||||
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
||||||
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
||||||
- name: Find Comment
|
- name: Find Comment
|
||||||
|
|||||||
Vendored
-1
@@ -4,7 +4,6 @@
|
|||||||
"yzhang.markdown-all-in-one", // nicer markdown support
|
"yzhang.markdown-all-in-one", // nicer markdown support
|
||||||
"esbenp.prettier-vscode", // prettier plugin
|
"esbenp.prettier-vscode", // prettier plugin
|
||||||
"dbaeumer.vscode-eslint", // eslint plugin
|
"dbaeumer.vscode-eslint", // eslint plugin
|
||||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
|
||||||
"ban.spellright", // Spell check for docs
|
"ban.spellright", // Spell check for docs
|
||||||
"stripe.vscode-stripe", // stripe VSCode extension
|
"stripe.vscode-stripe", // stripe VSCode extension
|
||||||
"Prisma.prisma", // syntax|format|completion for prisma
|
"Prisma.prisma", // syntax|format|completion for prisma
|
||||||
|
|||||||
Vendored
+1
-1
@@ -8,5 +8,5 @@
|
|||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"spellright.language": ["en"],
|
"spellright.language": ["en"],
|
||||||
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
|
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
|
||||||
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
|
"tailwindCSS.classFunctions": ["cva"]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
Vendored
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
Possible issue:
|
|
||||||
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
|
|
||||||
|
|
||||||
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
|
|
||||||
- Delete Credentials when DelegationCredential is disabled
|
|
||||||
|
|
||||||
|
|
||||||
TO Test
|
|
||||||
- New members beyond the batch size are processed
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
owner: {
|
owner: {
|
||||||
select: { username: true, id: true },
|
select: { username: true, id: true, timeZone: true },
|
||||||
},
|
},
|
||||||
children: {
|
children: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
|||||||
where: {
|
where: {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
role: {
|
||||||
|
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||||
|
},
|
||||||
team: {
|
team: {
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
organizationSettings: {
|
organizationSettings: {
|
||||||
isAdminAPIEnabled: true,
|
isAdminAPIEnabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
team: {
|
team: {
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export class BookingsController_2024_04_15 {
|
|||||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
try {
|
try {
|
||||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid);
|
||||||
|
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||||
const booking = await this.regularBookingService.createBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
@@ -459,7 +460,25 @@ export class BookingsController_2024_04_15 {
|
|||||||
return oAuthClientParams.platformClientId;
|
return oAuthClientParams.platformClientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
private async isValidRescheduleBooking(rescheduleUid: string, eventTypeId: number): Promise<boolean> {
|
||||||
|
const { bookingInfo } = await getBookingInfo(rescheduleUid);
|
||||||
|
if (!bookingInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bookingInfo.status !== "ACCEPTED" && bookingInfo.status !== "PENDING") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bookingInfo.eventTypeId !== eventTypeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkBookingRequiresAuthentication(
|
||||||
|
req: Request,
|
||||||
|
eventTypeId: number,
|
||||||
|
rescheduleUid?: string
|
||||||
|
): Promise<void> {
|
||||||
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
});
|
});
|
||||||
@@ -468,6 +487,17 @@ export class BookingsController_2024_04_15 {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rescheduleUid) {
|
||||||
|
const isValidRescheduleBooking = await this.isValidRescheduleBooking(rescheduleUid, eventTypeId);
|
||||||
|
if (isValidRescheduleBooking) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Trying to reschedule an event-type which requires authentication but provided invalid rescheduleUid."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userId = await this.getOwnerId(req);
|
const userId = await this.getOwnerId(req);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -478,8 +508,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
|
|
||||||
const isEventTypeOwner = eventType.userId === userId;
|
const isEventTypeOwner = eventType.userId === userId;
|
||||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||||
const isTeamAdminOrOwner =
|
const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||||
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
|
||||||
|
|
||||||
let isOrgAdminOrOwner = false;
|
let isOrgAdminOrOwner = false;
|
||||||
if (eventType.team?.parentId) {
|
if (eventType.team?.parentId) {
|
||||||
|
|||||||
@@ -194,11 +194,6 @@ export class CreateBookingInput_2024_04_15 {
|
|||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
skipContactOwner?: boolean;
|
skipContactOwner?: boolean;
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
@IsOptional()
|
|
||||||
@ApiHideProperty()
|
|
||||||
_shouldServeCache?: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
2. uid of one of the recurring booking recurrences
|
2. uid of one of the recurring booking recurrences
|
||||||
|
|
||||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
|
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
|
||||||
|
|
||||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all the recordings for the booking",
|
summary: "Get all the recordings for the booking",
|
||||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -229,9 +229,9 @@ export class BookingsController_2024_08_13 {
|
|||||||
@Get("/:bookingUid/transcripts")
|
@Get("/:bookingUid/transcripts")
|
||||||
@UseGuards(BookingUidGuard)
|
@UseGuards(BookingUidGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all the transcripts download links for the booking",
|
summary: "Get Cal Video real time transcript download links for the booking",
|
||||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -279,7 +279,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reschedule a booking",
|
summary: "Reschedule a booking",
|
||||||
description: `Reschedule a booking or seated booking
|
description: `Reschedule a booking or seated booking
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -321,7 +321,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Cancel a booking",
|
summary: "Cancel a booking",
|
||||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||||
|
|
||||||
\nCancelling normal bookings:
|
\nCancelling normal bookings:
|
||||||
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
||||||
|
|
||||||
@@ -329,11 +329,11 @@ export class BookingsController_2024_08_13 {
|
|||||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||||
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||||
|
|
||||||
\nCancelling recurring seated bookings:
|
\nCancelling recurring seated bookings:
|
||||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
|
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
|
||||||
|
|
||||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Mark a booking absence",
|
summary: "Mark a booking absence",
|
||||||
description: `The provided authorization header refers to the owner of the booking.
|
description: `The provided authorization header refers to the owner of the booking.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -399,15 +399,15 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reassign a booking to auto-selected host",
|
summary: "Reassign a booking to auto-selected host",
|
||||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
async reassignBooking(
|
async reassignBooking(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@GetUser() user: ApiAuthGuardUser
|
@GetUser() reassignedByUser: ApiAuthGuardUser
|
||||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -423,20 +423,20 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reassign a booking to a specific host",
|
summary: "Reassign a booking to a specific host",
|
||||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
async reassignBookingToUser(
|
async reassignBookingToUser(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@Param("userId") userId: number,
|
@Param("userId") userId: number,
|
||||||
@GetUser("id") reassignedById: number,
|
@GetUser() reassignedByUser: ApiAuthGuardUser,
|
||||||
@Body() body: ReassignToUserBookingInput_2024_08_13
|
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||||
const booking = await this.bookingsService.reassignBookingToUser(
|
const booking = await this.bookingsService.reassignBookingToUser(
|
||||||
bookingUid,
|
bookingUid,
|
||||||
userId,
|
userId,
|
||||||
reassignedById,
|
reassignedByUser,
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Confirm a booking",
|
summary: "Confirm a booking",
|
||||||
description: `The provided authorization header refers to the owner of the booking.
|
description: `The provided authorization header refers to the owner of the booking.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -478,8 +478,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Decline a booking",
|
summary: "Decline a booking",
|
||||||
description: `The provided authorization header refers to the owner of the booking.
|
description: `The provided authorization header refers to the owner of the booking.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
async declineBooking(
|
async declineBooking(
|
||||||
@@ -502,8 +502,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get 'Add to Calendar' links for a booking",
|
summary: "Get 'Add to Calendar' links for a booking",
|
||||||
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
|
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
|
||||||
|
|
||||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
import {
|
import { CreateBookingInput_2024_08_13, GetBookingOutput_2024_08_13, GetSeatedBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
CreateBookingInput_2024_08_13,
|
|
||||||
GetBookingOutput_2024_08_13,
|
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
|
||||||
} from "@calcom/platform-types";
|
|
||||||
import { BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
import { BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
import type { Booking, PlatformOAuthClient, Team, User, EventType } from "@calcom/prisma/client";
|
import type { Booking, PlatformOAuthClient, Team, User, EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -854,7 +850,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
describe("Booking Field Type Validation", () => {
|
describe("Booking Field Type Validation", () => {
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
start: "2025-06-19T11:00:00.000Z",
|
start: new Date(Date.UTC(2030, 5, 19, 11, 0, 0)).toISOString(),
|
||||||
attendee: {
|
attendee: {
|
||||||
name: "Charlie TypeTest",
|
name: "Charlie TypeTest",
|
||||||
email: "charlie.typetest@example.com",
|
email: "charlie.typetest@example.com",
|
||||||
@@ -1162,6 +1158,31 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should transform null values to empty strings in bookingFieldsResponses", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFields.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"favorite-movie": "The Matrix",
|
||||||
|
rescheduleReason: null,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(response.body.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = response.body.data;
|
||||||
|
expect(data.bookingFieldsResponses.notes).toBe("");
|
||||||
|
expect(data.bookingFieldsResponses.rescheduleReason).toBe("");
|
||||||
|
expect(data.bookingFieldsResponses["favorite-movie"]).toBe("The Matrix");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -1176,4 +1197,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+18
-17
@@ -12,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
|||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +72,7 @@ type EmailSetup = {
|
|||||||
team: Team;
|
team: Team;
|
||||||
member1: User;
|
member1: User;
|
||||||
member2: User;
|
member2: User;
|
||||||
|
member1ApiKey: string;
|
||||||
collectiveEventType: { id: number };
|
collectiveEventType: { id: number };
|
||||||
roundRobinEventType: { id: number };
|
roundRobinEventType: { id: number };
|
||||||
};
|
};
|
||||||
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
|
||||||
// Setup data for tests
|
// Setup data for tests
|
||||||
let emailsEnabledSetup: EmailSetup;
|
let emailsEnabledSetup: EmailSetup;
|
||||||
let emailsDisabledSetup: EmailSetup;
|
let emailsDisabledSetup: EmailSetup;
|
||||||
|
|
||||||
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
|
|
||||||
|
|
||||||
// Utility function to check response data type
|
// Utility function to check response data type
|
||||||
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
|
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
|
||||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await Test.createTestingModule({
|
||||||
authEmail,
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
Test.createTestingModule({
|
})
|
||||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.overrideGuard(PermissionsGuard)
|
.overrideGuard(PermissionsGuard)
|
||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
// Create a base organization for all tests
|
// Create a base organization for all tests
|
||||||
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
emailsEnabledSetup = await setupTestEnvironment(true);
|
emailsEnabledSetup = await setupTestEnvironment(true);
|
||||||
emailsDisabledSetup = await setupTestEnvironment(false);
|
emailsDisabledSetup = await setupTestEnvironment(false);
|
||||||
|
|
||||||
await userRepositoryFixture.create({
|
|
||||||
email: authEmail,
|
|
||||||
organization: { connect: { id: organization.id } },
|
|
||||||
});
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
await app.init();
|
await app.init();
|
||||||
@@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
|
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
|
||||||
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
|
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
|
||||||
|
|
||||||
|
// Create API key for member1 to use in authorized tests
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null);
|
||||||
|
const member1ApiKey = `cal_test_${keyString}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team,
|
team,
|
||||||
member1,
|
member1,
|
||||||
member2,
|
member2,
|
||||||
|
member1ApiKey,
|
||||||
collectiveEventType: { id: collectiveEvent.id },
|
collectiveEventType: { id: collectiveEvent.id },
|
||||||
roundRobinEventType: { id: roundRobinEvent.id },
|
roundRobinEventType: { id: roundRobinEvent.id },
|
||||||
};
|
};
|
||||||
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
: emailsDisabledSetup.member1.id;
|
: emailsDisabledSetup.member1.id;
|
||||||
const manualReassignResponse = await request(app.getHttpServer())
|
const manualReassignResponse = await request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||||
|
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
expect(manualReassignResponse.status).toBe(200);
|
expect(manualReassignResponse.status).toBe(200);
|
||||||
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
// --- 4. Automatic Reassign ---
|
// --- 4. Automatic Reassign ---
|
||||||
const autoReassignResponse = await request(app.getHttpServer())
|
const autoReassignResponse = await request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
expect(autoReassignResponse.status).toBe(200);
|
expect(autoReassignResponse.status).toBe(200);
|
||||||
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
|
|
||||||
const manualReassignResponse = await request(app.getHttpServer())
|
const manualReassignResponse = await request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||||
|
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
expect(manualReassignResponse.status).toBe(200);
|
expect(manualReassignResponse.status).toBe(200);
|
||||||
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const autoReassignResponse = await request(app.getHttpServer())
|
const autoReassignResponse = await request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
expect(autoReassignResponse.status).toBe(200);
|
expect(autoReassignResponse.status).toBe(200);
|
||||||
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Clean up database records
|
// Clean up database records
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
await userRepositoryFixture.deleteByEmail(authEmail);
|
|
||||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
|
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
|
||||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
|
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
|
||||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
|
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
|
||||||
|
|||||||
+67
-11
@@ -1,6 +1,7 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
|
import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
@@ -11,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
|
|||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
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 { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||||
@@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
|
||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
describe("Reassign bookings", () => {
|
describe("Reassign bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
|
||||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||||
@@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let teamUser1: User;
|
let teamUser1: User;
|
||||||
let teamUser2: User;
|
let teamUser2: User;
|
||||||
let teamUser3: User;
|
let teamUser3: User;
|
||||||
|
let teamUser1ApiKey: string;
|
||||||
|
let teamUser2ApiKey: string;
|
||||||
|
|
||||||
let teamRoundRobinEventTypeId: number;
|
let teamRoundRobinEventTypeId: number;
|
||||||
let teamRoundRobinFixedHostEventTypeId: number;
|
let teamRoundRobinFixedHostEventTypeId: number;
|
||||||
@@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let rescheduleReasonBookingInitialHostId: number;
|
let rescheduleReasonBookingInitialHostId: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await Test.createTestingModule({
|
||||||
teamUserEmail,
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
Test.createTestingModule({
|
})
|
||||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.overrideGuard(PermissionsGuard)
|
.overrideGuard(PermissionsGuard)
|
||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
@@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({
|
organization = await organizationsRepositoryFixture.create({
|
||||||
@@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null);
|
||||||
|
teamUser1ApiKey = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null);
|
||||||
|
teamUser2ApiKey = `cal_test_${keyString2}`;
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||||
.send(body)
|
.send(body)
|
||||||
|
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||||
|
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||||
|
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 403 when unauthorized user tries to reassign booking", async () => {
|
||||||
|
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||||
|
const unauthorizedUser = await userRepositoryFixture.create({
|
||||||
|
email: unauthorizedUserEmail,
|
||||||
|
locale: "en",
|
||||||
|
name: `fake-user-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||||
|
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||||
|
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||||
|
|
||||||
|
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => {
|
||||||
|
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
|
||||||
|
const unauthorizedUser = await userRepositoryFixture.create({
|
||||||
|
email: unauthorizedUserEmail,
|
||||||
|
locale: "en",
|
||||||
|
name: `fake-user-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||||
|
const unauthorizedApiKeyString = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`)
|
||||||
|
.send({ reason: "Testing unauthorized access" })
|
||||||
|
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||||
|
|
||||||
|
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
|
||||||
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
@@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import type {
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
RescheduleBookingInput_2024_08_13,
|
||||||
|
GetBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import type { EventType, User } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("Reschedule bookings 2024-08-13", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
|
||||||
|
let normalBookingUser: User;
|
||||||
|
let normalBookingEventType: EventType;
|
||||||
|
let normalBookingEventTypeId: number;
|
||||||
|
const normalBookingUserEmail = `user-bookings-normal-user-${randomString(5)}@api.com`;
|
||||||
|
const normalBookingEventTypeSlug = `user-bookings-normal-event-type-${randomString(5)}`;
|
||||||
|
let oldNormalBooking: BookingOutput_2024_08_13;
|
||||||
|
let newNormalBooking: BookingOutput_2024_08_13;
|
||||||
|
|
||||||
|
const RESCHEDULE_REASON = "Flying to venus that day";
|
||||||
|
const RESCHEDULED_BY = `user-venus-bookings-rescheduler-${randomString(5)}@api.com`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
normalBookingUserEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
// initialize normal booking user, schedule and event type
|
||||||
|
normalBookingUser = await userRepositoryFixture.create({
|
||||||
|
email: normalBookingUserEmail,
|
||||||
|
username: normalBookingUserEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: `user-bookings-normal-schedule-${randomString()}`,
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(normalBookingUser.id, userSchedule);
|
||||||
|
|
||||||
|
normalBookingEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `user-bookings-normal-event-type-${randomString()}`,
|
||||||
|
slug: normalBookingEventTypeSlug,
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
normalBookingUser.id
|
||||||
|
);
|
||||||
|
normalBookingEventTypeId = normalBookingEventType.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reschedule normal booking", () => {
|
||||||
|
it("should create a booking to be rescheduled", async () => {
|
||||||
|
const bookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 1, 9, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: normalBookingEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: `user-venus-bookings-attendee-${randomString(10)}`,
|
||||||
|
email: `user-venus-bookings-attendee-${randomString(10)}@gmail.com`,
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/oj12u83128u9312ou3",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(bookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingResponseBody: CreateBookingOutput_2024_08_13 = bookingResponse.body;
|
||||||
|
expect(bookingResponseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(bookingResponseBody.data)).toBe(true);
|
||||||
|
if (responseDataIsBooking(bookingResponseBody.data)) {
|
||||||
|
const created: BookingOutput_2024_08_13 = bookingResponseBody.data;
|
||||||
|
oldNormalBooking = created;
|
||||||
|
expect(oldNormalBooking).toBeDefined();
|
||||||
|
expect(oldNormalBooking.uid).toBeDefined();
|
||||||
|
} else {
|
||||||
|
throw new Error("should create a booking to be rescheduled - Invalid response data");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reschedule", async () => {
|
||||||
|
const body: RescheduleBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 1, 9, 12, 0, 0)).toISOString(),
|
||||||
|
reschedulingReason: RESCHEDULE_REASON,
|
||||||
|
rescheduledBy: RESCHEDULED_BY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeCreate = new Date();
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${oldNormalBooking.uid}/reschedule`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const afterCreate = new Date();
|
||||||
|
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data as BookingOutput_2024_08_13;
|
||||||
|
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
|
||||||
|
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
|
||||||
|
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||||
|
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2040, 1, 9, 13, 0, 0)).toISOString());
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts[0].id).toEqual(normalBookingUser.id);
|
||||||
|
expect(data.hosts[0].username).toEqual(normalBookingUser.username);
|
||||||
|
expect(data.hosts[0].email).toEqual(normalBookingUser.email);
|
||||||
|
expect(data.status).toEqual(oldNormalBooking.status);
|
||||||
|
expect(data.duration).toEqual(oldNormalBooking.duration);
|
||||||
|
expect(data.eventTypeId).toEqual(oldNormalBooking.eventTypeId);
|
||||||
|
expect(data.attendees[0]).toEqual(oldNormalBooking.attendees[0]);
|
||||||
|
expect(data.location).toEqual(oldNormalBooking.location);
|
||||||
|
expect(data.absentHost).toEqual(oldNormalBooking.absentHost);
|
||||||
|
expect(data.metadata).toEqual(oldNormalBooking.metadata);
|
||||||
|
|
||||||
|
newNormalBooking = data;
|
||||||
|
expect(newNormalBooking).toBeDefined();
|
||||||
|
|
||||||
|
const createdAtDate = new Date(data.createdAt);
|
||||||
|
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||||
|
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch old booking and verify rescheduledToUid and rescheduledByEmail and status cancelled", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings/${oldNormalBooking.uid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.rescheduledToUid).toEqual(newNormalBooking.uid);
|
||||||
|
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||||
|
expect(data.status).toEqual("cancelled");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch new booking and verify rescheduledFromUid, rescheduledByEmail and reschedulingReason", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings/${newNormalBooking.uid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.rescheduledFromUid).toEqual(oldNormalBooking.uid);
|
||||||
|
expect(data.rescheduledByEmail).toEqual(RESCHEDULED_BY);
|
||||||
|
expect(data.reschedulingReason).toEqual(RESCHEDULE_REASON);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userRepositoryFixture.deleteByEmail(normalBookingUser.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||||
|
}
|
||||||
|
});
|
||||||
+254
-356
@@ -79,7 +79,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
|
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
|
||||||
|
|
||||||
let createdBooking: BookingOutput_2024_08_13;
|
let createdBooking: BookingOutput_2024_08_13;
|
||||||
let rescheduledBooking: BookingOutput_2024_08_13;
|
|
||||||
let createdRecurringBooking: RecurringBookingOutput_2024_08_13[];
|
let createdRecurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||||
|
|
||||||
let bookingInThePast: Booking;
|
let bookingInThePast: Booking;
|
||||||
@@ -1360,139 +1359,217 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reschedule bookings", () => {
|
describe("rescheduling bookings", () => {
|
||||||
it("should reschedule normal booking", async () => {
|
describe("cant't reschedule cancelled or rescheduled booking booking", () => {
|
||||||
const body: RescheduleBookingInput_2024_08_13 = {
|
it("should not be able to reschedule cancelled booking", async () => {
|
||||||
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
|
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||||
reschedulingReason: "Flying to mars that day",
|
status: "CANCELLED",
|
||||||
};
|
user: {
|
||||||
|
connect: {
|
||||||
const beforeCreate = new Date();
|
id: user.id,
|
||||||
return request(app.getHttpServer())
|
},
|
||||||
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
|
},
|
||||||
.send(body)
|
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||||
.expect(201)
|
title: "peer coding lets goo",
|
||||||
.then(async (response) => {
|
uid: `cancelled-booking-${randomString()}`,
|
||||||
const afterCreate = new Date();
|
eventType: {
|
||||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
connect: {
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
id: eventTypeId,
|
||||||
expect(responseBody.data).toBeDefined();
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
},
|
||||||
// @ts-ignore
|
location: "integrations:daily",
|
||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
customInputs: {},
|
||||||
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
|
metadata: {},
|
||||||
expect(data.start).toEqual(body.start);
|
responses: {
|
||||||
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
|
name: "Oldie",
|
||||||
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
|
email: "oldie@gmail.com",
|
||||||
expect(data.id).toBeDefined();
|
},
|
||||||
expect(data.uid).toBeDefined();
|
attendees: {
|
||||||
expect(data.hosts[0].id).toEqual(user.id);
|
create: {
|
||||||
expect(data.hosts[0].username).toEqual(user.username);
|
email: "oldie@gmail.com",
|
||||||
expect(data.hosts[0].email).toEqual(user.email);
|
name: "Oldie",
|
||||||
expect(data.status).toEqual(createdBooking.status);
|
locale: "lv",
|
||||||
expect(data.duration).toEqual(createdBooking.duration);
|
timeZone: "Europe/Rome",
|
||||||
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
|
},
|
||||||
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
|
},
|
||||||
expect(data.location).toEqual(createdBooking.location);
|
|
||||||
expect(data.absentHost).toEqual(createdBooking.absentHost);
|
|
||||||
expect(data.metadata).toEqual(createdBooking.metadata);
|
|
||||||
|
|
||||||
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
|
|
||||||
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
|
|
||||||
const createdAtDate = new Date(data.createdAt);
|
|
||||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
|
||||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
|
||||||
|
|
||||||
rescheduledBooking = data;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const body: RescheduleBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||||
|
reschedulingReason: "Flying to mars that day",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toEqual(
|
||||||
|
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
||||||
|
);
|
||||||
|
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not be able to reschedule rescheduled booking", async () => {
|
||||||
|
const rescheduledBooking = await bookingsRepositoryFixture.create({
|
||||||
|
status: "CANCELLED",
|
||||||
|
rescheduled: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||||
|
title: "peer coding lets goo",
|
||||||
|
uid: `cancelled-booking-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "integrations:daily",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Oldie",
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
name: "Oldie",
|
||||||
|
locale: "lv",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newBooking = await bookingsRepositoryFixture.create({
|
||||||
|
status: "ACCEPTED",
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fromReschedule: rescheduledBooking.uid,
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||||
|
title: "peer coding lets goo",
|
||||||
|
uid: `new-booking-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "integrations:daily",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Oldie",
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
name: "Oldie",
|
||||||
|
locale: "lv",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: RescheduleBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||||
|
reschedulingReason: "Flying to mars that day",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toEqual(
|
||||||
|
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
|
||||||
|
);
|
||||||
|
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
|
||||||
|
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set rescheduled booking status to cancelled", async () => {
|
describe("recurring booking", () => {
|
||||||
return request(app.getHttpServer())
|
it("should reschedule recurrence of a recurring booking", async () => {
|
||||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
const body: RescheduleBookingInput_2024_08_13 = {
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
|
||||||
.expect(200)
|
reschedulingReason: "Flying to mars again",
|
||||||
.then(async (response) => {
|
};
|
||||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
expect(data.status).toEqual("cancelled");
|
|
||||||
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reschedule recurrence of a recurring booking", async () => {
|
return request(app.getHttpServer())
|
||||||
const body: RescheduleBookingInput_2024_08_13 = {
|
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
|
||||||
start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(),
|
.send(body)
|
||||||
reschedulingReason: "Flying to mars again",
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
};
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(data.hosts[0].username).toEqual(user.username);
|
||||||
|
expect(data.hosts[0].email).toEqual(user.email);
|
||||||
|
expect(data.status).toEqual(createdRecurringBooking[0].status);
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
|
||||||
|
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
||||||
|
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||||
|
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||||
|
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||||
|
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||||
|
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
||||||
.post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`)
|
expect(oldBooking).toBeDefined();
|
||||||
.send(body)
|
expect(oldBooking?.status).toEqual("CANCELLED");
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
});
|
||||||
.expect(201)
|
});
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const data: RecurringBookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
expect(data.id).toBeDefined();
|
|
||||||
expect(data.uid).toBeDefined();
|
|
||||||
expect(data.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(data.hosts[0].username).toEqual(user.username);
|
|
||||||
expect(data.hosts[0].email).toEqual(user.email);
|
|
||||||
expect(data.status).toEqual(createdRecurringBooking[0].status);
|
|
||||||
expect(data.start).toEqual(body.start);
|
|
||||||
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString());
|
|
||||||
expect(data.duration).toEqual(createdRecurringBooking[0].duration);
|
|
||||||
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
|
||||||
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
|
||||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
|
||||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
|
||||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
|
||||||
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
|
|
||||||
|
|
||||||
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
it("should get recurring booking recurrences after rescheduling one", async () => {
|
||||||
expect(oldBooking).toBeDefined();
|
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
|
||||||
expect(oldBooking?.status).toEqual("CANCELLED");
|
return request(app.getHttpServer())
|
||||||
});
|
.get(`/v2/bookings/${recurringBookingUid}`)
|
||||||
});
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
it("should get recurring booking recurrences after rescheduling one", async () => {
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid;
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
return request(app.getHttpServer())
|
expect(data.length).toEqual(4);
|
||||||
.get(`/v2/bookings/${recurringBookingUid}`)
|
const cancelled = data.find((booking) => booking.status === "cancelled");
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
expect(cancelled).toBeDefined();
|
||||||
.expect(200)
|
const rescheduledNew = data.find(
|
||||||
.then(async (response) => {
|
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
);
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(rescheduledNew).toBeDefined();
|
||||||
expect(responseBody.data).toBeDefined();
|
createdRecurringBooking = data;
|
||||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
} else {
|
||||||
|
throw new Error(
|
||||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
);
|
||||||
expect(data.length).toEqual(4);
|
}
|
||||||
const cancelled = data.find((booking) => booking.status === "cancelled");
|
});
|
||||||
expect(cancelled).toBeDefined();
|
});
|
||||||
const rescheduledNew = data.find(
|
|
||||||
(booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString()
|
|
||||||
);
|
|
||||||
expect(rescheduledNew).toBeDefined();
|
|
||||||
createdRecurringBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2385,95 +2462,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rescheduling booking with invalid booking responses", () => {
|
|
||||||
it("should reschedule", async () => {
|
|
||||||
const email = `user-bookings-attendee-${randomString(10)}@gmail.com`;
|
|
||||||
const username = `user-bookings-attendee-${randomString(10)}`;
|
|
||||||
const locale = "it";
|
|
||||||
const timeZone = "Europe/Rome";
|
|
||||||
const attendee = await userRepositoryFixture.create({
|
|
||||||
email,
|
|
||||||
username,
|
|
||||||
locale,
|
|
||||||
timeZone,
|
|
||||||
});
|
|
||||||
|
|
||||||
const booking = await bookingsRepositoryFixture.create({
|
|
||||||
uid: `booking-uid-${randomString(10)}`,
|
|
||||||
title: "booking title",
|
|
||||||
startTime: "2060-09-05T11:00:00.000Z",
|
|
||||||
endTime: "2060-09-05T12:00:00.000Z",
|
|
||||||
eventType: {
|
|
||||||
connect: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: "ACCEPTED",
|
|
||||||
metadata: {},
|
|
||||||
responses: "null",
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attendees: {
|
|
||||||
create: {
|
|
||||||
email: email,
|
|
||||||
name: username,
|
|
||||||
timeZone,
|
|
||||||
locale,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body: RescheduleBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(),
|
|
||||||
reschedulingReason: "Flying to mars that day",
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post(`/v2/bookings/${booking.uid}/reschedule`)
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
|
|
||||||
expect(data.start).toEqual(body.start);
|
|
||||||
expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString());
|
|
||||||
expect(data.rescheduledFromUid).toEqual(booking.uid);
|
|
||||||
expect(data.id).toBeDefined();
|
|
||||||
expect(data.uid).toBeDefined();
|
|
||||||
expect(data.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(data.hosts[0].username).toEqual(user.username);
|
|
||||||
expect(data.hosts[0].email).toEqual(user.email);
|
|
||||||
expect(data.status).toEqual("accepted");
|
|
||||||
expect(data.eventTypeId).toEqual(booking.eventTypeId);
|
|
||||||
expect(data.attendees.length).toEqual(1);
|
|
||||||
expect(data.attendees[0]).toEqual({
|
|
||||||
name: attendee.username,
|
|
||||||
email: attendee.email,
|
|
||||||
timeZone: attendee.timeZone,
|
|
||||||
language: attendee.locale,
|
|
||||||
absent: false,
|
|
||||||
});
|
|
||||||
expect(data.bookingFieldsResponses).toEqual({
|
|
||||||
name: attendee.username,
|
|
||||||
email: attendee.email,
|
|
||||||
rescheduledReason: body.reschedulingReason,
|
|
||||||
guests: [],
|
|
||||||
});
|
|
||||||
await bookingsRepositoryFixture.deleteById(data.id);
|
|
||||||
await userRepositoryFixture.delete(attendee.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("create booking with attendee sms reminder", () => {
|
describe("create booking with attendee sms reminder", () => {
|
||||||
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||||
const body: CreateBookingInput_2024_08_13 = {
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
@@ -2559,146 +2547,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cant't reschedule cancelled booking", () => {
|
|
||||||
it("should not be able to reschedule cancelled booking", async () => {
|
|
||||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
|
||||||
status: "CANCELLED",
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
|
||||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
|
||||||
title: "peer coding lets goo",
|
|
||||||
uid: `cancelled-booking-${randomString()}`,
|
|
||||||
eventType: {
|
|
||||||
connect: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
location: "integrations:daily",
|
|
||||||
customInputs: {},
|
|
||||||
metadata: {},
|
|
||||||
responses: {
|
|
||||||
name: "Oldie",
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
},
|
|
||||||
attendees: {
|
|
||||||
create: {
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
name: "Oldie",
|
|
||||||
locale: "lv",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body: RescheduleBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
|
||||||
reschedulingReason: "Flying to mars that day",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(400);
|
|
||||||
|
|
||||||
expect(response.body.error.message).toEqual(
|
|
||||||
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
|
||||||
);
|
|
||||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not be able to reschedule rescheduled booking", async () => {
|
|
||||||
const rescheduledBooking = await bookingsRepositoryFixture.create({
|
|
||||||
status: "CANCELLED",
|
|
||||||
rescheduled: true,
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
|
||||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
|
||||||
title: "peer coding lets goo",
|
|
||||||
uid: `cancelled-booking-${randomString()}`,
|
|
||||||
eventType: {
|
|
||||||
connect: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
location: "integrations:daily",
|
|
||||||
customInputs: {},
|
|
||||||
metadata: {},
|
|
||||||
responses: {
|
|
||||||
name: "Oldie",
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
},
|
|
||||||
attendees: {
|
|
||||||
create: {
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
name: "Oldie",
|
|
||||||
locale: "lv",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newBooking = await bookingsRepositoryFixture.create({
|
|
||||||
status: "ACCEPTED",
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fromReschedule: rescheduledBooking.uid,
|
|
||||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
|
||||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
|
||||||
title: "peer coding lets goo",
|
|
||||||
uid: `new-booking-${randomString()}`,
|
|
||||||
eventType: {
|
|
||||||
connect: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
location: "integrations:daily",
|
|
||||||
customInputs: {},
|
|
||||||
metadata: {},
|
|
||||||
responses: {
|
|
||||||
name: "Oldie",
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
},
|
|
||||||
attendees: {
|
|
||||||
create: {
|
|
||||||
email: "oldie@gmail.com",
|
|
||||||
name: "Oldie",
|
|
||||||
locale: "lv",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body: RescheduleBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
|
||||||
reschedulingReason: "Flying to mars that day",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(400);
|
|
||||||
|
|
||||||
expect(response.body.error.message).toEqual(
|
|
||||||
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
|
|
||||||
);
|
|
||||||
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
|
|
||||||
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cant't cancel already cancelled booking", () => {
|
describe("cant't cancel already cancelled booking", () => {
|
||||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||||
@@ -2752,6 +2600,56 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cancellation reason and cancelled by returned if null in database", () => {
|
||||||
|
it("should return cancellation reason and cancelled by if null in database", async () => {
|
||||||
|
const dbBooking = await bookingsRepositoryFixture.create({
|
||||||
|
uid: randomString(10),
|
||||||
|
title: "test",
|
||||||
|
startTime: new Date(Date.UTC(2040, 0, 13, 16, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2040, 0, 13, 17, 0, 0)),
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
guests: [],
|
||||||
|
},
|
||||||
|
cancellationReason: null,
|
||||||
|
cancelledBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings/${dbBooking.uid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.cancellationReason).toBe("");
|
||||||
|
expect(data.cancelledByEmail).toBe("");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("calendar events", () => {
|
describe("calendar events", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getByUidWithEventType(bookingUid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid: bookingUid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getByUidWithUser(bookingUid: string) {
|
async getByUidWithUser(bookingUid: string) {
|
||||||
return this.dbRead.prisma.booking.findUnique({
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
|
||||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
@@ -13,9 +13,8 @@ import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/ge
|
|||||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
|
||||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
@@ -24,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
|||||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -39,6 +38,8 @@ import { Request } from "express";
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTranslation,
|
getTranslation,
|
||||||
getAllUserBookings,
|
getAllUserBookings,
|
||||||
@@ -109,12 +110,11 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly organizationsRepository: OrganizationsRepository,
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
private readonly teamsRepository: TeamsRepository,
|
private readonly teamsRepository: TeamsRepository,
|
||||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
private readonly membershipsRepository: MembershipsRepository,
|
|
||||||
private readonly membershipsService: MembershipsService,
|
|
||||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
|
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
|
||||||
private readonly regularBookingService: RegularBookingService,
|
private readonly regularBookingService: RegularBookingService,
|
||||||
private readonly recurringBookingService: RecurringBookingService,
|
private readonly recurringBookingService: RecurringBookingService,
|
||||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||||
|
private readonly eventTypeAccessService: EventTypeAccessService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||||
@@ -128,7 +128,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||||
}
|
}
|
||||||
const userIsEventTypeAdminOrOwner = authUser
|
const userIsEventTypeAdminOrOwner = authUser
|
||||||
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
|
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||||
: false;
|
: false;
|
||||||
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
|
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
|
||||||
|
|
||||||
@@ -197,44 +197,6 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType) {
|
|
||||||
const authUserId = authUser.id;
|
|
||||||
const authUserRole = authUser.role;
|
|
||||||
const eventTypeId = eventType.id;
|
|
||||||
const teamId = eventType.teamId;
|
|
||||||
const eventTypeOwnerId = eventType.userId || null;
|
|
||||||
|
|
||||||
if (authUserRole === "ADMIN") return true;
|
|
||||||
|
|
||||||
if (eventTypeOwnerId === authUserId) return true;
|
|
||||||
|
|
||||||
if (eventTypeId) {
|
|
||||||
const [isUserHost, isUserAssigned] = await Promise.all([
|
|
||||||
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
|
|
||||||
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isUserHost || isUserAssigned) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (teamId) {
|
|
||||||
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
|
|
||||||
authUserId,
|
|
||||||
teamId
|
|
||||||
);
|
|
||||||
if (membership) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
eventTypeOwnerId &&
|
|
||||||
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId))
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBookedEventType(body: CreateBookingInput) {
|
async getBookedEventType(body: CreateBookingInput) {
|
||||||
if (body.eventTypeId) {
|
if (body.eventTypeId) {
|
||||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||||
@@ -616,7 +578,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||||
const userIsEventTypeAdminOrOwner =
|
const userIsEventTypeAdminOrOwner =
|
||||||
authUser && booking?.eventType
|
authUser && booking?.eventType
|
||||||
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (booking) {
|
if (booking) {
|
||||||
@@ -805,7 +767,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const userIsEventTypeAdminOrOwner =
|
const userIsEventTypeAdminOrOwner =
|
||||||
authUser && databaseBooking.eventType
|
authUser && databaseBooking.eventType
|
||||||
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||||
: false;
|
: false;
|
||||||
const isRecurring = !!databaseBooking.recurringEventId;
|
const isRecurring = !!databaseBooking.recurringEventId;
|
||||||
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||||
@@ -1032,19 +994,34 @@ export class BookingsService_2024_08_13 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
|
||||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!booking.eventType) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||||
|
reassignedByUser,
|
||||||
|
booking.eventType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
const platformClientParams = booking.eventTypeId
|
const platformClientParams = booking.eventTypeId
|
||||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
const profile = this.usersService.getUserMainProfile(reassignedByUser);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await roundRobinReassignment({
|
await roundRobinReassignment({
|
||||||
@@ -1052,7 +1029,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
orgId: profile?.organizationId || null,
|
orgId: profile?.organizationId || null,
|
||||||
emailsEnabled,
|
emailsEnabled,
|
||||||
platformClientParams,
|
platformClientParams,
|
||||||
reassignedById: requestUser.id,
|
reassignedById: reassignedByUser.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -1076,13 +1053,27 @@ export class BookingsService_2024_08_13 {
|
|||||||
async reassignBookingToUser(
|
async reassignBookingToUser(
|
||||||
bookingUid: string,
|
bookingUid: string,
|
||||||
newUserId: number,
|
newUserId: number,
|
||||||
reassignedById: number,
|
reassignedByUser: ApiAuthGuardUser,
|
||||||
body: ReassignToUserBookingInput_2024_08_13
|
body: ReassignToUserBookingInput_2024_08_13
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
}
|
}
|
||||||
|
if (!booking.eventType) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Event type with id=${booking.eventTypeId} was not found in the database`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||||
|
reassignedByUser,
|
||||||
|
booking.eventType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1103,7 +1094,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
newUserId,
|
newUserId,
|
||||||
orgId: profile?.organizationId || null,
|
orgId: profile?.organizationId || null,
|
||||||
reassignReason: body.reason,
|
reassignReason: body.reason,
|
||||||
reassignedById,
|
reassignedById: reassignedByUser.id,
|
||||||
emailsEnabled,
|
emailsEnabled,
|
||||||
platformClientParams,
|
platformClientParams,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -544,12 +544,18 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
|
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
|
||||||
newRequest.body = {
|
newRequest.body = {
|
||||||
...bodyTransformed,
|
...bodyTransformed,
|
||||||
|
rescheduledBy: request.body.rescheduledBy,
|
||||||
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
|
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
|
||||||
creationSource: CreationSource.API_V2,
|
creationSource: CreationSource.API_V2,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
Object.assign(newRequest, { userId, platformBookingLocation: location });
|
Object.assign(newRequest, { userId, platformBookingLocation: location });
|
||||||
newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 };
|
newRequest.body = {
|
||||||
|
...bodyTransformed,
|
||||||
|
noEmail: false,
|
||||||
|
creationSource: CreationSource.API_V2,
|
||||||
|
rescheduledBy: request.body.rescheduledBy,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return newRequest as unknown as BookingRequest;
|
return newRequest as unknown as BookingRequest;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import type {
|
|||||||
ReassignBookingOutput_2024_08_13,
|
ReassignBookingOutput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import type { Booking, BookingSeat } from "@calcom/prisma/client";
|
import type { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
|
||||||
|
|
||||||
export const bookingResponsesSchema = z
|
export const bookingResponsesSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -105,14 +104,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled
|
|
||||||
? rescheduledToInfo?.rescheduledBy
|
|
||||||
: databaseBooking.rescheduledBy;
|
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -121,9 +114,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason:
|
cancellationReason: databaseBooking.cancellationReason || "",
|
||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
cancelledByEmail: databaseBooking.cancelledBy || "",
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
@@ -168,6 +160,23 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getRescheduledToUid(databaseBooking: DatabaseBooking) {
|
||||||
|
if (!databaseBooking.rescheduled) return undefined;
|
||||||
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(databaseBooking.uid);
|
||||||
|
return rescheduledTo?.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRescheduledByEmail(databaseBooking: DatabaseBooking) {
|
||||||
|
if (databaseBooking.rescheduled) {
|
||||||
|
return databaseBooking.rescheduledBy;
|
||||||
|
}
|
||||||
|
if (databaseBooking.fromReschedule) {
|
||||||
|
const previousBooking = await this.bookingsRepository.getByUid(databaseBooking.fromReschedule);
|
||||||
|
return previousBooking?.rescheduledBy ?? databaseBooking.rescheduledBy;
|
||||||
|
}
|
||||||
|
return databaseBooking.rescheduledBy;
|
||||||
|
}
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -227,10 +236,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason:
|
cancellationReason: databaseBooking.cancellationReason || "",
|
||||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
cancelledByEmail: databaseBooking.cancelledBy || "",
|
||||||
cancelledByEmail:
|
|
||||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
|
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
@@ -284,14 +291,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToUid = await this.getRescheduledToUid(databaseBooking);
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
const rescheduledByEmail = await this.getRescheduledByEmail(databaseBooking);
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled
|
|
||||||
? rescheduledToInfo?.rescheduledBy
|
|
||||||
: databaseBooking.rescheduledBy;
|
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -300,8 +301,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason:
|
cancellationReason: databaseBooking.cancellationReason || "",
|
||||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -428,8 +428,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason:
|
cancellationReason: databaseBooking.cancellationReason || "",
|
||||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ export class EventTypesService_2024_04_15 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
await this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||||
const bookingFields = [...(body.bookingFields || [])];
|
const bookingFields = [...(body.bookingFields || [])];
|
||||||
|
|
||||||
|
|||||||
+131
-8
@@ -22,17 +22,31 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
|
import {
|
||||||
|
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||||
|
BookerLayoutsInputEnum_2024_06_14,
|
||||||
|
ConfirmationPolicyEnum,
|
||||||
|
NoticeThresholdUnitEnum,
|
||||||
|
FrequencyInput,
|
||||||
|
} from "@calcom/platform-enums";
|
||||||
import { SchedulingType } from "@calcom/platform-libraries";
|
import { SchedulingType } from "@calcom/platform-libraries";
|
||||||
import { type ApiSuccessResponse, type CreateEventTypeInput_2024_06_14, type EventTypeOutput_2024_06_14, type GuestsDefaultFieldOutput_2024_06_14, type NameDefaultFieldInput_2024_06_14, type NotesDefaultFieldInput_2024_06_14, type SplitNameDefaultFieldOutput_2024_06_14, type UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
import {
|
||||||
|
BaseConfirmationPolicy_2024_06_14,
|
||||||
|
TeamEventTypeOutput_2024_06_14,
|
||||||
|
type ApiSuccessResponse,
|
||||||
|
type CreateEventTypeInput_2024_06_14,
|
||||||
|
type EventTypeOutput_2024_06_14,
|
||||||
|
type GuestsDefaultFieldOutput_2024_06_14,
|
||||||
|
type NameDefaultFieldInput_2024_06_14,
|
||||||
|
type NotesDefaultFieldInput_2024_06_14,
|
||||||
|
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||||
|
type UpdateEventTypeInput_2024_06_14,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
|
||||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||||
if (a.slug < b.slug) return -1;
|
if (a.slug < b.slug) return -1;
|
||||||
if (a.slug > b.slug) return 1;
|
if (a.slug > b.slug) return 1;
|
||||||
@@ -83,6 +97,8 @@ describe("Event types Endpoints", () => {
|
|||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
let apiKeyOrgUser: string;
|
let apiKeyOrgUser: string;
|
||||||
|
let systemAdminUser: User;
|
||||||
|
let systemAdminApiKeyString: string;
|
||||||
|
|
||||||
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
|
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
|
||||||
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
|
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
|
||||||
@@ -196,6 +212,17 @@ describe("Event types Endpoints", () => {
|
|||||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
apiKeyString = `cal_test_${keyString}`;
|
apiKeyString = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
systemAdminUser = await userRepositoryFixture.create({
|
||||||
|
email: `event-types-2024-06-14-system-admin-${randomString()}@api.com`,
|
||||||
|
username: `event-types-2024-06-14-system-admin-${randomString()}`,
|
||||||
|
role: "ADMIN",
|
||||||
|
});
|
||||||
|
const { keyString: adminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
systemAdminUser.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
systemAdminApiKeyString = `cal_test_${adminKeyString}`;
|
||||||
|
|
||||||
orgUser = await userRepositoryFixture.create({
|
orgUser = await userRepositoryFixture.create({
|
||||||
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
|
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
|
||||||
name: `event-types-2024-06-14-org-user-${randomString()}`,
|
name: `event-types-2024-06-14-org-user-${randomString()}`,
|
||||||
@@ -1354,6 +1381,9 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||||
expect(fetchedEventType.ownerId).toEqual(user.id);
|
expect(fetchedEventType.ownerId).toEqual(user.id);
|
||||||
|
expect(Array.isArray(fetchedEventType.users)).toEqual(true);
|
||||||
|
expect(fetchedEventType.users.length).toEqual(1);
|
||||||
|
expect(fetchedEventType.users[0].id).toEqual(fetchedEventType.ownerId);
|
||||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||||
@@ -1375,6 +1405,99 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("system admin can access another user's event type by id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/event-types/${orgUserEventType1.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.set("Authorization", `Bearer ${systemAdminApiKeyString}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(orgUserEventType1.id);
|
||||||
|
expect(responseBody.data.ownerId).toEqual(orgUser.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("user can access a team event type as team member (using user's API key)", async () => {
|
||||||
|
const team = await teamRepositoryFixture.create({
|
||||||
|
name: `event-types-2024-06-14-team-${randomString()}`,
|
||||||
|
isOrganization: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
title: `event-types-2024-06-14-team-event-${randomString()}`,
|
||||||
|
slug: `event-types-2024-06-14-team-event-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
locations: [],
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/event-types/${teamEventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(teamEventType.id);
|
||||||
|
expect(responseBody.data.teamId).toEqual(team.id);
|
||||||
|
await teamRepositoryFixture.delete(team.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("user can access a team event type as HOST even if membership role is MEMBER (using user's API key)", async () => {
|
||||||
|
const team = await teamRepositoryFixture.create({
|
||||||
|
name: `event-types-2024-06-14-host-team-${randomString()}`,
|
||||||
|
isOrganization: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
title: `event-types-2024-06-14-host-event-${randomString()}`,
|
||||||
|
slug: `event-types-2024-06-14-host-event-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
locations: [],
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
hosts: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/event-types/${teamEventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(teamEventType.id);
|
||||||
|
expect(responseBody.data.teamId).toEqual(team.id);
|
||||||
|
await teamRepositoryFixture.delete(team.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
||||||
@@ -1780,7 +1903,7 @@ describe("Event types Endpoints", () => {
|
|||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
const updatedEventType = responseBody.data;
|
const updatedEventType = responseBody.data;
|
||||||
const policy = updatedEventType.confirmationPolicy as any;
|
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
||||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
||||||
expect(policy?.noticeThreshold).toBeUndefined();
|
expect(policy?.noticeThreshold).toBeUndefined();
|
||||||
@@ -1808,7 +1931,7 @@ describe("Event types Endpoints", () => {
|
|||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
const updatedEventType = responseBody.data;
|
const updatedEventType = responseBody.data;
|
||||||
const policy = updatedEventType.confirmationPolicy as any;
|
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
||||||
expect(policy?.noticeThreshold).toEqual({
|
expect(policy?.noticeThreshold).toEqual({
|
||||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||||
@@ -2793,4 +2916,4 @@ describe("Event types Endpoints", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-5
@@ -7,6 +7,7 @@ import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_202
|
|||||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
|
import type { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||||
import {
|
import {
|
||||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||||
@@ -23,6 +24,9 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||||
|
import type { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -73,7 +77,8 @@ export class EventTypesController_2024_06_14 {
|
|||||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
|
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
|
||||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
|
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||||
|
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -107,24 +112,44 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get an event type",
|
summary: "Get an event type",
|
||||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
|
|
||||||
|
Access control: This endpoint fetches an event type by ID and returns it only if the authenticated user is authorized. Authorization is granted to:
|
||||||
|
- System admins
|
||||||
|
- The event type owner
|
||||||
|
- Hosts of the event type or users assigned to the event type
|
||||||
|
- Team admins/owners of the team that owns the team event type
|
||||||
|
- Organization admins/owners of the event type owner's organization
|
||||||
|
- Organization admins/owners of the team's parent organization
|
||||||
|
|
||||||
|
Note: Update and delete endpoints remain restricted to the event type owner only.`,
|
||||||
})
|
})
|
||||||
async getEventTypeById(
|
async getEventTypeById(
|
||||||
@Param("eventTypeId") eventTypeId: string,
|
@Param("eventTypeId") eventTypeId: string,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: ApiAuthGuardUser
|
||||||
): Promise<GetEventTypeOutput_2024_06_14> {
|
): Promise<GetEventTypeOutput_2024_06_14> {
|
||||||
const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
|
const eventType = await this.eventTypesService.getEventTypeByIdIfAuthorized(user, Number(eventTypeId));
|
||||||
|
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseEventType = this.isTeamEventType(eventType)
|
||||||
|
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
|
||||||
|
: this.eventTypeResponseTransformPipe.transform(eventType);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
data: responseEventType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isTeamEventType(
|
||||||
|
eventType: DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType)
|
||||||
|
): eventType is DatabaseTeamEventType {
|
||||||
|
return !!eventType.teamId;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all event types",
|
summary: "Get all event types",
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
|
|||||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||||
|
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||||
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
@@ -26,6 +31,8 @@ import { Module } from "@nestjs/common";
|
|||||||
EventTypesService_2024_06_14,
|
EventTypesService_2024_06_14,
|
||||||
InputEventTypesService_2024_06_14,
|
InputEventTypesService_2024_06_14,
|
||||||
OutputEventTypesService_2024_06_14,
|
OutputEventTypesService_2024_06_14,
|
||||||
|
EventTypeAccessService,
|
||||||
|
TeamsRepository,
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
UsersService,
|
UsersService,
|
||||||
SchedulesRepository_2024_06_11,
|
SchedulesRepository_2024_06_11,
|
||||||
@@ -35,6 +42,9 @@ import { Module } from "@nestjs/common";
|
|||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
AppsRepository,
|
AppsRepository,
|
||||||
CalendarsRepository,
|
CalendarsRepository,
|
||||||
|
OutputTeamEventTypesResponsePipe,
|
||||||
|
OutputOrganizationsEventTypesService,
|
||||||
|
TeamsEventTypesRepository,
|
||||||
],
|
],
|
||||||
controllers: [EventTypesController_2024_06_14],
|
controllers: [EventTypesController_2024_06_14],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -42,6 +52,7 @@ import { Module } from "@nestjs/common";
|
|||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
InputEventTypesService_2024_06_14,
|
InputEventTypesService_2024_06_14,
|
||||||
OutputEventTypesService_2024_06_14,
|
OutputEventTypesService_2024_06_14,
|
||||||
|
EventTypeAccessService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class EventTypesModule_2024_06_14 {}
|
export class EventTypesModule_2024_06_14 {}
|
||||||
|
|||||||
@@ -93,6 +93,19 @@ export class EventTypesRepository_2024_06_14 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEventTypeByIdWithHosts(eventTypeId: number) {
|
||||||
|
return this.dbRead.prisma.eventType.findUnique({
|
||||||
|
where: { id: eventTypeId },
|
||||||
|
include: {
|
||||||
|
users: true,
|
||||||
|
schedule: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
calVideoSettings: true,
|
||||||
|
hosts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getEventTypeByIdIncludeUsersAndTeam(eventTypeId: number) {
|
async getEventTypeByIdIncludeUsersAndTeam(eventTypeId: number) {
|
||||||
const eventType = await this.dbRead.prisma.eventType.findUnique({
|
const eventType = await this.dbRead.prisma.eventType.findUnique({
|
||||||
where: { id: eventTypeId },
|
where: { id: eventTypeId },
|
||||||
|
|||||||
+10
-7
@@ -1,19 +1,22 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsIn, ValidateNested } from "class-validator";
|
import { IsIn } from "class-validator";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
import { EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14)
|
||||||
export class GetEventTypeOutput_2024_06_14 {
|
export class GetEventTypeOutput_2024_06_14 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: EventTypeOutput_2024_06_14,
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(EventTypeOutput_2024_06_14) },
|
||||||
|
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@Type(() => Object)
|
||||||
@Type(() => EventTypeOutput_2024_06_14)
|
data!: EventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14 | null;
|
||||||
data!: EventTypeOutput_2024_06_14 | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-2
@@ -1,10 +1,14 @@
|
|||||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||||
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||||
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -30,7 +34,8 @@ export class EventTypesService_2024_06_14 {
|
|||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||||
private readonly dbWrite: PrismaWriteService,
|
private readonly dbWrite: PrismaWriteService,
|
||||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11
|
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||||
|
private readonly eventTypeAccessService: EventTypeAccessService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
|
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
|
||||||
@@ -39,7 +44,7 @@ export class EventTypesService_2024_06_14 {
|
|||||||
}
|
}
|
||||||
await this.checkCanCreateEventType(user.id, body);
|
await this.checkCanCreateEventType(user.id, body);
|
||||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||||
|
|
||||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -79,6 +84,31 @@ export class EventTypesService_2024_06_14 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEventTypeByIdIfAuthorized(
|
||||||
|
authUser: ApiAuthGuardUser,
|
||||||
|
eventTypeId: number
|
||||||
|
): Promise<DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType) | null> {
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeByIdWithHosts(eventTypeId);
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
|
||||||
|
authUser,
|
||||||
|
eventType as unknown as EventType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerId: eventType.userId ?? 0,
|
||||||
|
...eventType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
|
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
|
||||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||||
if (existsWithSlug) {
|
if (existsWithSlug) {
|
||||||
@@ -233,6 +263,10 @@ export class EventTypesService_2024_06_14 {
|
|||||||
return [dynamicEventType];
|
return [dynamicEventType];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authUser?.id) {
|
||||||
|
return await this.getUserEventTypes(authUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe("Me Endpoints", () => {
|
|||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
const userEmail = `me-controller-user-${randomString()}@api.com`;
|
const userEmail = `me-controller-user-${randomString()}@api.com`;
|
||||||
|
const name = "Me Controller User";
|
||||||
let user: User;
|
let user: User;
|
||||||
let org: Team;
|
let org: Team;
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ describe("Me Endpoints", () => {
|
|||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
username: userEmail,
|
username: userEmail,
|
||||||
|
name,
|
||||||
});
|
});
|
||||||
|
|
||||||
org = await organizationsRepositoryFixture.create({
|
org = await organizationsRepositoryFixture.create({
|
||||||
@@ -92,6 +94,9 @@ describe("Me Endpoints", () => {
|
|||||||
|
|
||||||
expect(responseBody.data.id).toEqual(user.id);
|
expect(responseBody.data.id).toEqual(user.id);
|
||||||
expect(responseBody.data.email).toEqual(user.email);
|
expect(responseBody.data.email).toEqual(user.email);
|
||||||
|
expect(responseBody.data.name).toEqual(user.name);
|
||||||
|
expect(responseBody.data.avatarUrl).toEqual(user.avatarUrl);
|
||||||
|
expect(responseBody.data.bio).toEqual(user.bio);
|
||||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||||
@@ -114,6 +119,8 @@ describe("Me Endpoints", () => {
|
|||||||
|
|
||||||
expect(responseBody.data.id).toEqual(user.id);
|
expect(responseBody.data.id).toEqual(user.id);
|
||||||
expect(responseBody.data.email).toEqual(user.email);
|
expect(responseBody.data.email).toEqual(user.email);
|
||||||
|
expect(responseBody.data.avatarUrl).toEqual(user.avatarUrl);
|
||||||
|
expect(responseBody.data.bio).toEqual(user.bio);
|
||||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||||
@@ -148,13 +155,13 @@ describe("Me Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not update user associated with access token given invalid time format", async () => {
|
it("should not update user associated with access token given invalid time format", async () => {
|
||||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
|
const bodyWithIncorrectTimeFormat = { timeFormat: 100 };
|
||||||
|
|
||||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not update user associated with access token given invalid week start", async () => {
|
it("should not update user associated with access token given invalid week start", async () => {
|
||||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
|
const bodyWithIncorrectWeekStart = { weekStart: "waba luba dub dub" };
|
||||||
|
|
||||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ export class MeOutput {
|
|||||||
@DocsProperty()
|
@DocsProperty()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@DocsProperty({ type: String, nullable: true })
|
||||||
|
name!: string | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@DocsProperty({ type: String, nullable: true })
|
||||||
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@DocsProperty({ type: String, nullable: true })
|
||||||
|
bio!: string | null;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@DocsProperty()
|
@DocsProperty()
|
||||||
timeFormat!: number;
|
timeFormat!: number;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
|
|||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||||
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||||
@@ -35,7 +34,6 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaFeaturesRepository,
|
PrismaFeaturesRepository,
|
||||||
PrismaMembershipRepository,
|
PrismaMembershipRepository,
|
||||||
CheckBookingLimitsService,
|
CheckBookingLimitsService,
|
||||||
CacheService,
|
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
UserAvailabilityService,
|
UserAvailabilityService,
|
||||||
BusyTimesService,
|
BusyTimesService,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
|
|||||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
|
||||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||||
@@ -22,7 +21,6 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaHostRepository,
|
PrismaHostRepository,
|
||||||
PrismaOOORepository,
|
PrismaOOORepository,
|
||||||
PrismaUserRepository,
|
PrismaUserRepository,
|
||||||
CacheService,
|
|
||||||
CheckBookingAndDurationLimitsService,
|
CheckBookingAndDurationLimitsService,
|
||||||
CheckBookingLimitsService,
|
CheckBookingLimitsService,
|
||||||
HashedLinkService,
|
HashedLinkService,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
|
|||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||||
@@ -34,7 +33,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
featuresRepository: PrismaFeaturesRepository,
|
featuresRepository: PrismaFeaturesRepository,
|
||||||
qualifiedHostsService: QualifiedHostsService,
|
qualifiedHostsService: QualifiedHostsService,
|
||||||
checkBookingLimitsService: CheckBookingLimitsService,
|
checkBookingLimitsService: CheckBookingLimitsService,
|
||||||
cacheService: CacheService,
|
|
||||||
userAvailabilityService: UserAvailabilityService,
|
userAvailabilityService: UserAvailabilityService,
|
||||||
busyTimesService: BusyTimesService,
|
busyTimesService: BusyTimesService,
|
||||||
noSlotsNotificationService: NoSlotsNotificationService
|
noSlotsNotificationService: NoSlotsNotificationService
|
||||||
@@ -50,7 +48,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
redisClient: redisService,
|
redisClient: redisService,
|
||||||
checkBookingLimitsService,
|
checkBookingLimitsService,
|
||||||
cacheService,
|
|
||||||
userAvailabilityService,
|
userAvailabilityService,
|
||||||
busyTimesService,
|
busyTimesService,
|
||||||
qualifiedHostsService,
|
qualifiedHostsService,
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
|
||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CacheService extends BaseCacheService {
|
|
||||||
constructor(featuresRepository: PrismaFeaturesRepository) {
|
|
||||||
super({ featuresRepository });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationMembershipService extends BaseOrganizationMembershipService {
|
||||||
|
constructor(organizationsRepository: OrganizationsRepository) {
|
||||||
|
super({ organizationRepository: organizationsRepository });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
|
||||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||||
@@ -13,7 +12,6 @@ import type { PrismaClient } from "@calcom/prisma";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class RegularBookingService extends BaseRegularBookingService {
|
export class RegularBookingService extends BaseRegularBookingService {
|
||||||
constructor(
|
constructor(
|
||||||
cacheService: CacheService,
|
|
||||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
||||||
prismaWriteService: PrismaWriteService,
|
prismaWriteService: PrismaWriteService,
|
||||||
bookingRepository: PrismaBookingRepository,
|
bookingRepository: PrismaBookingRepository,
|
||||||
@@ -22,7 +20,6 @@ export class RegularBookingService extends BaseRegularBookingService {
|
|||||||
userRepository: PrismaUserRepository
|
userRepository: PrismaUserRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
cacheService,
|
|
||||||
checkBookingAndDurationLimitsService,
|
checkBookingAndDurationLimitsService,
|
||||||
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
||||||
bookingRepository,
|
bookingRepository,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
|||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
|
import { RolesModule } from "@/modules/roles/roles.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
|
RolesModule,
|
||||||
],
|
],
|
||||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
exports: [NextAuthGuard, ApiAuthGuard],
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
export const Pbac = Reflector.createDecorator<PermissionString[]>();
|
||||||
@@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||||
|
|
||||||
if (org?.isOrganization && !org?.isPlatform) {
|
if (org?.isOrganization && !org?.isPlatform) {
|
||||||
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||||
|
|
||||||
if (org?.isOrganization) {
|
if (org?.isOrganization) {
|
||||||
canAccess = true;
|
canAccess = true;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
|
||||||
|
|
||||||
if (org?.isOrganization) {
|
if (org?.isOrganization) {
|
||||||
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
|
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
BadRequestException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`;
|
||||||
|
export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = (
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requiredPermissions: PermissionString[]
|
||||||
|
) =>
|
||||||
|
`apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions
|
||||||
|
.sort()
|
||||||
|
.join(",")}:guard:pbac`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PbacGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private prismaReadService: PrismaReadService,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
const teamId = request.params.teamId;
|
||||||
|
const orgId = request.params.orgId;
|
||||||
|
const requiredPermissions = this.reflector.get(Pbac, context.getHandler());
|
||||||
|
|
||||||
|
const effectiveTeamId = teamId || orgId;
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided");
|
||||||
|
}
|
||||||
|
if (!effectiveTeamId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"PbacGuard - can't check pbac because no teamId or orgId provided within the request url"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
request.pbacAuthorizedRequest = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId));
|
||||||
|
if (!hasPbacEnabled) {
|
||||||
|
request.pbacAuthorizedRequest = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredPermissions = await this.checkUserHasRequiredPermissions(
|
||||||
|
user.id,
|
||||||
|
Number(effectiveTeamId),
|
||||||
|
requiredPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredPermissions) {
|
||||||
|
request.pbacAuthorizedRequest = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.pbacAuthorizedRequest = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasPbacEnabled(teamId: number) {
|
||||||
|
const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId);
|
||||||
|
|
||||||
|
if (cachedHasPbacEnabled) {
|
||||||
|
return cachedHasPbacEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pbacFeatureFlag = "pbac";
|
||||||
|
const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma);
|
||||||
|
const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag);
|
||||||
|
|
||||||
|
if (hasPbacEnabled) {
|
||||||
|
await this.setCachePbacEnabled(teamId, hasPbacEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPbacEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUserHasRequiredPermissions(
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requiredPermissions: PermissionString[]
|
||||||
|
) {
|
||||||
|
const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions);
|
||||||
|
|
||||||
|
if (cachedAccess) {
|
||||||
|
return cachedAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionCheckService = new PermissionCheckService();
|
||||||
|
const hasRequiredPermissions = await permissionCheckService.checkPermissions({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
permissions: requiredPermissions,
|
||||||
|
fallbackRoles: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRequiredPermissions) {
|
||||||
|
await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRequiredPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCacheRequiredPermissions(
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requiredPermissions: PermissionString[]
|
||||||
|
): Promise<boolean | null> {
|
||||||
|
return this.redisService.get<boolean>(
|
||||||
|
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setCacheRequiredPermissions(
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requiredPermissions: PermissionString[],
|
||||||
|
hasRequired: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redisService.set<boolean>(
|
||||||
|
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
|
||||||
|
hasRequired,
|
||||||
|
{ ttl: 300_000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCachePbacEnabled(teamId: number) {
|
||||||
|
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
|
||||||
|
await this.redisService.set<boolean>(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, {
|
||||||
|
ttl: 300_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throwForbiddenError(
|
||||||
|
userId: number,
|
||||||
|
teamId: string,
|
||||||
|
orgId: string,
|
||||||
|
requiredPermissions: PermissionString[]
|
||||||
|
) {
|
||||||
|
let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join(
|
||||||
|
","
|
||||||
|
)} `;
|
||||||
|
if (teamId) {
|
||||||
|
errorMessage += `within team with id=${teamId}`;
|
||||||
|
}
|
||||||
|
if (orgId) {
|
||||||
|
errorMessage += `within organization with id=${orgId}`;
|
||||||
|
}
|
||||||
|
errorMessage += `.`;
|
||||||
|
|
||||||
|
throw new ForbiddenException(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger }
|
|||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import type { Team } from "@calcom/prisma/client";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
private readonly logger = new Logger("RolesGuard Logger");
|
private readonly logger = new Logger("RolesGuard Logger");
|
||||||
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
|
||||||
|
|
||||||
|
if (request.pbacAuthorizedRequest === true) {
|
||||||
|
this.logger.debug("PBAC authorized request, skipping legacy role checking");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const teamId = request.params.teamId as string;
|
const teamId = request.params.teamId as string;
|
||||||
const orgId = request.params.orgId as string;
|
const orgId = request.params.orgId as string;
|
||||||
const user = request.user as ApiAuthGuardUser;
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
|||||||
@@ -305,11 +305,24 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
|||||||
new Date(invoice.period_end * 1000)
|
new Date(invoice.period_end * 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const existingSubscription = await this.stripeService
|
||||||
|
.getStripe()
|
||||||
|
.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const perActiveUserPrice = this.billingConfigService.get(PlatformPlan.PER_ACTIVE_USER)?.base;
|
||||||
|
const subscriptionItem = existingSubscription.items.data.find(
|
||||||
|
(item) => item.price?.id === perActiveUserPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subscriptionItem) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"No subscription item found for PER_ACTIVE_USER plan with matching price ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||||
items: [
|
items: [
|
||||||
{
|
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
|
||||||
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||||
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import type { EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventTypeAccessService {
|
||||||
|
constructor(
|
||||||
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
|
private readonly membershipsRepository: MembershipsRepository,
|
||||||
|
private readonly membershipsService: MembershipsService,
|
||||||
|
private readonly teamsRepository: TeamsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType): Promise<boolean> {
|
||||||
|
const authUserId = authUser.id;
|
||||||
|
const eventTypeId = eventType.id;
|
||||||
|
const teamId = eventType.teamId;
|
||||||
|
const eventTypeOwnerId = eventType.userId || null;
|
||||||
|
|
||||||
|
if (authUser.isSystemAdmin) return true;
|
||||||
|
|
||||||
|
if (eventTypeOwnerId === authUserId) return true;
|
||||||
|
|
||||||
|
if (eventTypeId) {
|
||||||
|
const isHostOrAssigned = await this.isUserHostOrAssignedToEventType(authUserId, eventTypeId);
|
||||||
|
if (isHostOrAssigned) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamId) {
|
||||||
|
const isTeamOrParentOrgAdmin = await this.isUserTeamAdminOrParentOrgAdmin(authUserId, teamId);
|
||||||
|
if (isTeamOrParentOrgAdmin) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventTypeOwnerId) {
|
||||||
|
const isOrgAdminOrOwnerOfEventOwner = await this.isUserOrgAdminOrOwnerOfEventOwner(
|
||||||
|
authUserId,
|
||||||
|
eventTypeOwnerId
|
||||||
|
);
|
||||||
|
if (isOrgAdminOrOwnerOfEventOwner) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isUserHostOrAssignedToEventType(authUserId: number, eventTypeId: number): Promise<boolean> {
|
||||||
|
const [isUserHost, isUserAssigned] = await Promise.all([
|
||||||
|
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
|
||||||
|
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
||||||
|
]);
|
||||||
|
return isUserHost || isUserAssigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isUserTeamAdminOrParentOrgAdmin(authUserId: number, teamId: number): Promise<boolean> {
|
||||||
|
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(authUserId, teamId);
|
||||||
|
if (membership) return true;
|
||||||
|
|
||||||
|
const team = await this.teamsRepository.getById(teamId);
|
||||||
|
const parentOrgId = team?.parentId ?? null;
|
||||||
|
if (parentOrgId) {
|
||||||
|
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(authUserId, parentOrgId);
|
||||||
|
if (isOrgAdmin) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isUserOrgAdminOrOwnerOfEventOwner(
|
||||||
|
authUserId: number,
|
||||||
|
eventTypeOwnerId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
HostPriority,
|
HostPriority,
|
||||||
EmailSettings_2024_06_14,
|
EmailSettings_2024_06_14,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
import type { EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
export const HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR =
|
||||||
|
"Hosts required when switching schedulingType. Please provide 'hosts' or set 'assignAllTeamMembers: true' to specify how hosts should be configured for the new scheduling type.";
|
||||||
|
|
||||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||||
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
||||||
@@ -116,10 +120,12 @@ export class InputOrganizationsEventTypesService {
|
|||||||
teamId: number,
|
teamId: number,
|
||||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||||
) {
|
) {
|
||||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||||
|
|
||||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||||
|
|
||||||
|
const isManagedEventType = rest.schedulingType === "MANAGED";
|
||||||
|
|
||||||
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
|
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
|
||||||
{
|
{
|
||||||
type: "integration",
|
type: "integration",
|
||||||
@@ -127,12 +133,13 @@ export class InputOrganizationsEventTypesService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
const children = isManagedEventType
|
||||||
|
? await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
let metadata =
|
let metadata = isManagedEventType
|
||||||
rest.schedulingType === "MANAGED"
|
? { managedEventConfig: {}, ...eventType.metadata }
|
||||||
? { managedEventConfig: {}, ...eventType.metadata }
|
: eventType.metadata;
|
||||||
: eventType.metadata;
|
|
||||||
|
|
||||||
if (emailSettings) {
|
if (emailSettings) {
|
||||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||||
@@ -140,12 +147,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
|
|
||||||
const teamEventType = {
|
const teamEventType = {
|
||||||
...eventType,
|
...eventType,
|
||||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
hosts: await this.transformInputCreateTeamEventTypeHosts(teamId, inputEventType),
|
||||||
hosts: !(rest.schedulingType === "MANAGED")
|
|
||||||
? assignAllTeamMembers
|
|
||||||
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
|
||||||
: this.transformInputHosts(hosts, inputEventType.schedulingType)
|
|
||||||
: undefined,
|
|
||||||
assignAllTeamMembers,
|
assignAllTeamMembers,
|
||||||
locations: this.transformInputTeamLocations(locations || defaultLocations),
|
locations: this.transformInputTeamLocations(locations || defaultLocations),
|
||||||
metadata,
|
metadata,
|
||||||
@@ -155,6 +157,24 @@ export class InputOrganizationsEventTypesService {
|
|||||||
return teamEventType;
|
return teamEventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async transformInputCreateTeamEventTypeHosts(
|
||||||
|
teamId: number,
|
||||||
|
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||||
|
) {
|
||||||
|
const { hosts, assignAllTeamMembers, schedulingType } = inputEventType;
|
||||||
|
|
||||||
|
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children - each child managed event type is associated with
|
||||||
|
// a specific user and hosts property is only for team event types e.g round robin and collective.
|
||||||
|
if (schedulingType === "MANAGED") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignAllTeamMembers) {
|
||||||
|
return await this.getAllTeamMembers(teamId, inputEventType.schedulingType);
|
||||||
|
}
|
||||||
|
return this.transformInputHosts(hosts, inputEventType.schedulingType);
|
||||||
|
}
|
||||||
|
|
||||||
private addEmailSettingsToMetadata(
|
private addEmailSettingsToMetadata(
|
||||||
emailSettings: EmailSettings_2024_06_14,
|
emailSettings: EmailSettings_2024_06_14,
|
||||||
metadata: NonNullable<EventTypeMetadata>
|
metadata: NonNullable<EventTypeMetadata>
|
||||||
@@ -190,7 +210,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
teamId: number,
|
teamId: number,
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||||
) {
|
) {
|
||||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
const { assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||||
|
|
||||||
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
||||||
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||||
@@ -213,11 +233,11 @@ export class InputOrganizationsEventTypesService {
|
|||||||
const teamEventType = {
|
const teamEventType = {
|
||||||
...eventType,
|
...eventType,
|
||||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||||
hosts: !children
|
hosts: await this.transformInputUpdateTeamEventTypeHosts(
|
||||||
? assignAllTeamMembers
|
teamId,
|
||||||
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
dbEventType.schedulingType,
|
||||||
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
inputEventType
|
||||||
: undefined,
|
),
|
||||||
assignAllTeamMembers,
|
assignAllTeamMembers,
|
||||||
children,
|
children,
|
||||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||||
@@ -227,6 +247,33 @@ export class InputOrganizationsEventTypesService {
|
|||||||
return teamEventType;
|
return teamEventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async transformInputUpdateTeamEventTypeHosts(
|
||||||
|
teamId: number,
|
||||||
|
dbEventTypeSchedulingType: EventType["schedulingType"],
|
||||||
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||||
|
) {
|
||||||
|
const { hosts, assignAllTeamMembers } = inputEventType;
|
||||||
|
|
||||||
|
if (dbEventTypeSchedulingType === "MANAGED") {
|
||||||
|
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the event type children
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSchedulingTypeChanging =
|
||||||
|
inputEventType.schedulingType && inputEventType.schedulingType !== dbEventTypeSchedulingType;
|
||||||
|
|
||||||
|
if (isSchedulingTypeChanging && !assignAllTeamMembers && !hosts) {
|
||||||
|
throw new BadRequestException(HOSTS_REQUIRED_WHEN_SWITCHING_SCHEDULING_TYPE_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSchedulingType = inputEventType.schedulingType || dbEventTypeSchedulingType;
|
||||||
|
if (assignAllTeamMembers) {
|
||||||
|
return await this.getAllTeamMembers(teamId, nextSchedulingType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.transformInputHosts(hosts, nextSchedulingType);
|
||||||
|
}
|
||||||
|
|
||||||
async getChildEventTypesForManagedEventTypeUpdate(
|
async getChildEventTypesForManagedEventTypeUpdate(
|
||||||
eventTypeId: number,
|
eventTypeId: number,
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||||
@@ -273,7 +320,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChildEventTypesForManagedEventTypeCreate(
|
async getChildEventTypesForManagedEventTypeCreate(
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">,
|
||||||
teamId: number
|
teamId: number
|
||||||
) {
|
) {
|
||||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||||
@@ -289,7 +336,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
|
|
||||||
async getOwnersIdsForManagedEventTypeCreate(
|
async getOwnersIdsForManagedEventTypeCreate(
|
||||||
teamId: number,
|
teamId: number,
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">
|
||||||
) {
|
) {
|
||||||
if (inputEventType.assignAllTeamMembers) {
|
if (inputEventType.assignAllTeamMembers) {
|
||||||
return await this.getTeamUsersIds(teamId);
|
return await this.getTeamUsersIds(teamId);
|
||||||
|
|||||||
@@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsRepository {
|
export class OrganizationsRepository extends OrganizationRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dbRead: PrismaReadService,
|
private readonly dbRead: PrismaReadService,
|
||||||
private readonly dbWrite: PrismaWriteService,
|
private readonly dbWrite: PrismaWriteService,
|
||||||
private readonly stripeService: StripeService
|
private readonly stripeService: StripeService
|
||||||
) {}
|
) {
|
||||||
|
super({ prismaClient: dbWrite.prisma });
|
||||||
async findById(organizationId: number) {
|
|
||||||
return this.dbRead.prisma.team.findUnique({
|
|
||||||
where: {
|
|
||||||
id: organizationId,
|
|
||||||
isOrganization: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIds(organizationIds: number[]) {
|
async findByIds(organizationIds: number[]) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class OrganizationsService {
|
|||||||
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
|
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
|
||||||
|
|
||||||
async isPlatform(organizationId: number) {
|
async isPlatform(organizationId: number) {
|
||||||
const organization = await this.organizationsRepository.findById(organizationId);
|
const organization = await this.organizationsRepository.findById({ id: organizationId });
|
||||||
return organization?.isPlatform;
|
return organization?.isPlatform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
|||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { EmailModule } from "@/modules/email/email.module";
|
import { EmailModule } from "@/modules/email/email.module";
|
||||||
import { EmailService } from "@/modules/email/email.service";
|
import { EmailService } from "@/modules/email/email.service";
|
||||||
|
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
||||||
@@ -39,6 +40,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
|
|||||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||||
|
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
|
||||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||||
@@ -50,6 +52,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team
|
|||||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||||
|
import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module";
|
||||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||||
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
||||||
@@ -96,6 +99,8 @@ import { Module } from "@nestjs/common";
|
|||||||
TeamsModule,
|
TeamsModule,
|
||||||
OrganizationsDelegationCredentialModule,
|
OrganizationsDelegationCredentialModule,
|
||||||
OrganizationsOrganizationsModule,
|
OrganizationsOrganizationsModule,
|
||||||
|
OrganizationsRolesModule,
|
||||||
|
OrganizationsTeamsRolesModule,
|
||||||
OrganizationsStripeModule,
|
OrganizationsStripeModule,
|
||||||
OrganizationsTeamsRoutingFormsModule,
|
OrganizationsTeamsRoutingFormsModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
@@ -104,6 +109,7 @@ import { Module } from "@nestjs/common";
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
|
OrganizationMembershipService,
|
||||||
OrganizationsTeamsRepository,
|
OrganizationsTeamsRepository,
|
||||||
OrganizationsService,
|
OrganizationsService,
|
||||||
OrganizationsTeamsService,
|
OrganizationsTeamsService,
|
||||||
@@ -196,4 +202,4 @@ import { Module } from "@nestjs/common";
|
|||||||
OrganizationsEventTypesPrivateLinksController,
|
OrganizationsEventTypesPrivateLinksController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OrganizationsModule {}
|
export class OrganizationsModule { }
|
||||||
|
|||||||
+2
-2
@@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
|
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
|
||||||
const organization = await this.organizationsRepository.findById(managerOrganizationId);
|
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
|
||||||
return !!organization?.isPlatform;
|
return !!organization?.isPlatform;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManagedOrganization(managedOrganizationId: number) {
|
async getManagedOrganization(managedOrganizationId: number) {
|
||||||
const organization = await this.organizationsRepository.findById(managedOrganizationId);
|
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
|
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator";
|
||||||
|
|
||||||
|
export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const;
|
||||||
|
|
||||||
|
export class BaseOrgRoleInput {
|
||||||
|
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Description of the role" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||||
|
enum: orgPermissionEnum,
|
||||||
|
isArray: true,
|
||||||
|
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(OrgPermissionStringValidator, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
permissions?: PermissionString[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||||
|
|
||||||
|
export class CreateOrgRoleInput extends BaseOrgRoleInput {
|
||||||
|
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
import { BaseOrgRoleInput } from "./base-org-role.input";
|
||||||
|
|
||||||
|
export class UpdateOrgRoleInput extends BaseOrgRoleInput {
|
||||||
|
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
+638
@@ -0,0 +1,638 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||||
|
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||||
|
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||||
|
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||||
|
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||||
|
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||||
|
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||||
|
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 { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.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 { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||||
|
import type { User, Team } from "@calcom/prisma/client";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
describe("Organizations Roles Endpoints", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||||
|
let roleService: RoleService;
|
||||||
|
|
||||||
|
// Test users
|
||||||
|
let legacyOrgAdminUser: User;
|
||||||
|
let legacyOrgMemberUser: User;
|
||||||
|
let pbacOrgUserWithRolePermission: User;
|
||||||
|
let pbacOrgUserWithoutRolePermission: User;
|
||||||
|
let nonOrgUser: User;
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
let legacyOrgAdminApiKey: string;
|
||||||
|
let legacyOrgMemberApiKey: string;
|
||||||
|
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||||
|
let pbacOrgUserWithoutRolePermissionApiKey: string;
|
||||||
|
let nonOrgUserApiKey: string;
|
||||||
|
|
||||||
|
// Organization
|
||||||
|
let organization: Team;
|
||||||
|
let pbacEnabledOrganization: Team;
|
||||||
|
|
||||||
|
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
|
||||||
|
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
|
||||||
|
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
|
||||||
|
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
|
||||||
|
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||||
|
roleService = new RoleService();
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
legacyOrgAdminUser = await userRepositoryFixture.create({
|
||||||
|
email: legacyOrgAdminEmail,
|
||||||
|
username: legacyOrgAdminEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
legacyOrgMemberUser = await userRepositoryFixture.create({
|
||||||
|
email: legacyOrgMemberEmail,
|
||||||
|
username: legacyOrgMemberEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||||
|
email: pbacOrgUserWithRolePermissionEmail,
|
||||||
|
username: pbacOrgUserWithRolePermissionEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
|
||||||
|
email: pbacOrgUserWithoutRolePermissionEmail,
|
||||||
|
username: pbacOrgUserWithoutRolePermissionEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
nonOrgUser = await userRepositoryFixture.create({
|
||||||
|
email: nonOrgUserEmail,
|
||||||
|
username: nonOrgUserEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create organizations
|
||||||
|
organization = await organizationsRepositoryFixture.create({
|
||||||
|
name: `org-roles-test-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||||
|
name: `pbac-org-roles-test-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||||
|
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||||
|
|
||||||
|
// Create memberships
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: legacyOrgAdminUser.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: legacyOrgMemberUser.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||||
|
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
|
||||||
|
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleWithPermission = await roleService.createRole({
|
||||||
|
name: "Role Manager",
|
||||||
|
teamId: pbacEnabledOrganization.id,
|
||||||
|
permissions: ["role.create", "role.read", "role.update", "role.delete"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleWithoutPermission = await roleService.createRole({
|
||||||
|
name: "Basic Role",
|
||||||
|
teamId: pbacEnabledOrganization.id,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
|
||||||
|
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
|
||||||
|
await roleService.assignRoleToMember(
|
||||||
|
roleWithoutPermission.id,
|
||||||
|
pbacOrgUserWithoutRolePermissionMembership.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
legacyOrgAdminUser.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
|
||||||
|
|
||||||
|
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
legacyOrgMemberUser.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
|
||||||
|
|
||||||
|
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
pbacOrgUserWithRolePermission.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
|
||||||
|
|
||||||
|
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
|
||||||
|
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
|
||||||
|
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
|
||||||
|
|
||||||
|
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
nonOrgUser.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Role Creation Authorization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Positive Tests", () => {
|
||||||
|
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role PBAC",
|
||||||
|
permissions: ["booking.read", "eventType.create"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||||
|
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||||
|
PbacGuard.prototype as unknown as PbacGuard,
|
||||||
|
"hasPbacEnabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||||
|
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||||
|
RolesGuard.prototype as unknown as RolesGuard,
|
||||||
|
"checkUserRoleAccess"
|
||||||
|
);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(createRoleInput)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||||
|
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
|
||||||
|
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||||
|
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||||
|
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||||
|
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||||
|
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||||
|
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||||
|
expect(pbacCanActivateResult).toBe(true);
|
||||||
|
expect(rolesCanActivateResult).toBe(true);
|
||||||
|
expect(hasPbac).toBe(true);
|
||||||
|
expect(rolesSpyCheckUserRoleAccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role Legacy Admin",
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
|
||||||
|
const pbacSpyHasPbacEnabled = jest.spyOn(
|
||||||
|
PbacGuard.prototype as unknown as PbacGuard,
|
||||||
|
"hasPbacEnabled"
|
||||||
|
);
|
||||||
|
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
|
||||||
|
const rolesSpyCheckUserRoleAccess = jest.spyOn(
|
||||||
|
RolesGuard.prototype as unknown as RolesGuard,
|
||||||
|
"checkUserRoleAccess"
|
||||||
|
);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${organization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
|
||||||
|
.send(createRoleInput)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(organization.id);
|
||||||
|
|
||||||
|
expect(pbacSpyCanActivate).toHaveBeenCalled();
|
||||||
|
expect(rolesSpyCanActivate).toHaveBeenCalled();
|
||||||
|
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
|
||||||
|
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
|
||||||
|
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
|
||||||
|
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
|
||||||
|
expect(pbacCanActivateResult).toBe(true);
|
||||||
|
expect(rolesCanActivateResult).toBe(true);
|
||||||
|
expect(hasPbac).toBe(false);
|
||||||
|
expect(rolesSpyCheckUserRoleAccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Negative Tests", () => {
|
||||||
|
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role No Role",
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const userWithNoRole = await userRepositoryFixture.create({
|
||||||
|
email: `no-role-user-${randomString()}@api.com`,
|
||||||
|
username: `no-role-user-${randomString()}@api.com`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: userWithNoRole.id } },
|
||||||
|
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
userWithNoRole.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${noRoleApiKey}`)
|
||||||
|
.send(createRoleInput);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`RolesGuard - user with id=${userWithNoRole.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role No Permission",
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
|
||||||
|
.send(createRoleInput);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`RolesGuard - user with id=${pbacOrgUserWithoutRolePermission.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role No Membership",
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${organization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
|
||||||
|
.send(createRoleInput);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`RolesGuard - User is not a member of the organization with id=${organization.id}.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "Test Role Member Only",
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${organization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
|
||||||
|
.send(createRoleInput);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`RolesGuard - user with id=${legacyOrgMemberUser.id} does not have the minimum required role=ORG_ADMIN within organization with id=${organization.id}.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CRUD Role Endpoints", () => {
|
||||||
|
let createdRoleId: string;
|
||||||
|
|
||||||
|
it("should create a role", async () => {
|
||||||
|
const createRoleInput: CreateOrgRoleInput = {
|
||||||
|
name: "CRUD Test Role",
|
||||||
|
permissions: ["booking.read", "eventType.create"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(createRoleInput)
|
||||||
|
.expect(201)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: CreateOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.name).toEqual(createRoleInput.name);
|
||||||
|
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
createdRoleId = responseBody.data.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update role permissions and name", async () => {
|
||||||
|
const updateRoleInput: UpdateOrgRoleInput = {
|
||||||
|
name: "CRUD Test Role Updated",
|
||||||
|
permissions: ["booking.read", "eventType.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(updateRoleInput)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: UpdateOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||||
|
expect(responseBody.data.name).toEqual(updateRoleInput.name);
|
||||||
|
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch the role", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: GetOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch all roles", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: GetAllOrgRolesOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||||
|
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
|
||||||
|
const created = responseBody.data.find((r) => r.id === createdRoleId);
|
||||||
|
expect(created?.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete the role", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: DeleteOrgRoleOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(createdRoleId);
|
||||||
|
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Negative error cases", () => {
|
||||||
|
it("should fail to create a role with a duplicate name (400)", async () => {
|
||||||
|
const name = `dup-role-${randomString()}`;
|
||||||
|
|
||||||
|
const firstCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(firstCreate)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const secondCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(secondCreate);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(`Role with name "${name}" already exists`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to create a role with invalid permissions (400)", async () => {
|
||||||
|
const createRoleInput = {
|
||||||
|
name: `invalid-perms-${randomString()}`,
|
||||||
|
permissions: ["invalid"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(createRoleInput);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when updating a role not belonging to the organization", async () => {
|
||||||
|
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||||
|
const updateRoleInput: UpdateOrgRoleInput = {
|
||||||
|
name: `no-update-default-${randomString()}`,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(updateRoleInput);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when updating a default (system) role not belonging to the organization", async () => {
|
||||||
|
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
|
||||||
|
const updateRoleInput: UpdateOrgRoleInput = {
|
||||||
|
name: `no-update-default-${randomString()}`,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(updateRoleInput);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to update with invalid permissions (400)", async () => {
|
||||||
|
// Create a fresh role to update
|
||||||
|
const { body: createRes } = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ name: `update-invalid-${randomString()}` as string, permissions: ["booking.read"] })
|
||||||
|
.expect(201);
|
||||||
|
const roleId: string = createRes.data.id;
|
||||||
|
|
||||||
|
const updateRoleInput = {
|
||||||
|
name: `update-invalid-${randomString()}`,
|
||||||
|
permissions: ["invalid"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(updateRoleInput);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when updating a role from a different organization", async () => {
|
||||||
|
// Create a role in a different org (legacy/non-PBAC org)
|
||||||
|
const foreignRole = await roleService.createRole({
|
||||||
|
name: `foreign-update-${randomString()}`,
|
||||||
|
teamId: organization.id,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: ["eventType.read"] });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when deleting a role not belonging to the organization", async () => {
|
||||||
|
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when deleting a default (system) role not belonging to the organization", async () => {
|
||||||
|
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 when deleting a role from a different organization", async () => {
|
||||||
|
// Create a role in a different org
|
||||||
|
const foreignRole = await roleService.createRole({
|
||||||
|
name: `foreign-delete-${randomString()}`,
|
||||||
|
teamId: organization.id,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||||
|
|
||||||
|
await organizationsRepositoryFixture.delete(organization.id);
|
||||||
|
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||||
|
|
||||||
|
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cleanup error:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cleanup error:", error);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import {
|
||||||
|
OPTIONAL_API_KEY_HEADER,
|
||||||
|
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||||
|
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||||
|
} from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.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 { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
|
||||||
|
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
|
||||||
|
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
|
||||||
|
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
|
||||||
|
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
|
||||||
|
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
|
||||||
|
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
|
||||||
|
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
|
||||||
|
import { RolesService } from "@/modules/roles/services/roles.service";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
UseGuards,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Query,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/roles",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@DocsTags("Orgs / Roles")
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
|
export class OrganizationsRolesController {
|
||||||
|
constructor(
|
||||||
|
private readonly rolesService: RolesService,
|
||||||
|
private readonly rolesOutputService: OrganizationsRolesOutputService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.create"])
|
||||||
|
@Post("/")
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: "Create a new organization role" })
|
||||||
|
async createRole(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Body() body: CreateOrgRoleInput
|
||||||
|
): Promise<CreateOrgRoleOutput> {
|
||||||
|
const role = await this.rolesService.createRole(orgId, body);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.read"])
|
||||||
|
@Get("/:roleId")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Get a specific organization role" })
|
||||||
|
async getRole(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string
|
||||||
|
): Promise<GetOrgRoleOutput> {
|
||||||
|
const role = await this.rolesService.getRole(orgId, roleId);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.read"])
|
||||||
|
@Get("/")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Get all organization roles" })
|
||||||
|
async getAllRoles(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Query() queryParams: SkipTakePagination
|
||||||
|
): Promise<GetAllOrgRolesOutput> {
|
||||||
|
const { skip, take } = queryParams;
|
||||||
|
const roles = await this.rolesService.getTeamRoles(orgId, skip ?? 0, take ?? 250);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: this.rolesOutputService.getOrganizationRolesOutput(roles),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.update"])
|
||||||
|
@Patch("/:roleId")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Update an organization role" })
|
||||||
|
async updateRole(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string,
|
||||||
|
@Body() body: UpdateOrgRoleInput
|
||||||
|
): Promise<UpdateOrgRoleOutput> {
|
||||||
|
const role = await this.rolesService.updateRole(orgId, roleId, body);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.delete"])
|
||||||
|
@Delete("/:roleId")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Delete an organization role" })
|
||||||
|
async deleteRole(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string
|
||||||
|
): Promise<DeleteOrgRoleOutput> {
|
||||||
|
const role = await this.rolesService.deleteRole(orgId, roleId);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: this.rolesOutputService.getOrganizationRoleOutput(role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsRolesPermissionsController } from "@/modules/organizations/roles/permissions/organizations-roles-permissions.controller";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
|
import { RolesModule } from "@/modules/roles/roles.module";
|
||||||
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { OrganizationsRolesController } from "./organizations-roles.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
|
||||||
|
providers: [OrganizationsRepository],
|
||||||
|
controllers: [OrganizationsRolesController, OrganizationsRolesPermissionsController],
|
||||||
|
})
|
||||||
|
export class OrganizationsRolesModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class CreateOrgRoleOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: OrgRoleOutput,
|
||||||
|
})
|
||||||
|
@IsNotEmptyObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgRoleOutput)
|
||||||
|
data!: OrgRoleOutput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class DeleteOrgRoleOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: OrgRoleOutput,
|
||||||
|
})
|
||||||
|
@IsNotEmptyObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgRoleOutput)
|
||||||
|
data!: OrgRoleOutput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsArray, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class GetAllOrgRolesOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [OrgRoleOutput],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => OrgRoleOutput)
|
||||||
|
data!: OrgRoleOutput[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class GetOrgRoleOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: OrgRoleOutput,
|
||||||
|
})
|
||||||
|
@IsNotEmptyObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgRoleOutput)
|
||||||
|
data!: OrgRoleOutput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Expose } from "class-transformer";
|
||||||
|
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
import { orgPermissionEnum } from "../inputs/base-org-role.input";
|
||||||
|
|
||||||
|
enum RoleTypeEnum {
|
||||||
|
SYSTEM = "SYSTEM",
|
||||||
|
CUSTOM = "CUSTOM",
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleType = keyof typeof RoleTypeEnum;
|
||||||
|
|
||||||
|
export class OrgRoleOutput {
|
||||||
|
@ApiProperty({ description: "Unique identifier for the role" })
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Name of the role" })
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Expose()
|
||||||
|
color?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Description of the role" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Expose()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Organization ID this role belongs to" })
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Expose()
|
||||||
|
organizationId?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Type of role",
|
||||||
|
enum: RoleTypeEnum,
|
||||||
|
})
|
||||||
|
@IsEnum(RoleTypeEnum)
|
||||||
|
@Expose()
|
||||||
|
type!: RoleType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Permissions assigned to this role in 'resource.action' format.",
|
||||||
|
enum: orgPermissionEnum,
|
||||||
|
isArray: true,
|
||||||
|
example: ["booking.read", "eventType.create"],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Expose()
|
||||||
|
permissions!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: "When the role was created" })
|
||||||
|
@IsDateString()
|
||||||
|
@Expose()
|
||||||
|
createdAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "When the role was last updated" })
|
||||||
|
@IsDateString()
|
||||||
|
@Expose()
|
||||||
|
updatedAt!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class UpdateOrgRoleOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: OrgRoleOutput,
|
||||||
|
})
|
||||||
|
@IsNotEmptyObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgRoleOutput)
|
||||||
|
data!: OrgRoleOutput;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsString, Validate } from "class-validator";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||||
|
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||||
|
|
||||||
|
export class CreateOrgRolePermissionsInput {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Permissions to add (format: resource.action)",
|
||||||
|
enum: orgPermissionEnum,
|
||||||
|
isArray: true,
|
||||||
|
example: ["eventType.read", "booking.read"],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(OrgPermissionStringValidator, { each: true })
|
||||||
|
permissions!: PermissionString[];
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
|
||||||
|
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
|
||||||
|
|
||||||
|
export class DeleteOrgRolePermissionsQuery {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
|
||||||
|
example: "?permissions=eventType.read,booking.read",
|
||||||
|
enum: orgPermissionEnum,
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((p: string) => p.trim())
|
||||||
|
.filter((p: string) => p.length > 0);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty({ message: "permissions cannot be empty." })
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(OrgPermissionStringValidator, { each: true })
|
||||||
|
permissions?: PermissionString[];
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { OrgPermissionStringValidator } from "./org-permission-string.validator";
|
||||||
|
|
||||||
|
describe("PermissionStringValidator", () => {
|
||||||
|
let validator: OrgPermissionStringValidator;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
validator = new OrgPermissionStringValidator();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate", () => {
|
||||||
|
it("should return true for valid permission strings", () => {
|
||||||
|
const validPermissions = [
|
||||||
|
"eventType.read",
|
||||||
|
"eventType.create",
|
||||||
|
"eventType.update",
|
||||||
|
"eventType.delete",
|
||||||
|
"booking.read",
|
||||||
|
"booking.update",
|
||||||
|
"role.create",
|
||||||
|
"role.read",
|
||||||
|
"role.update",
|
||||||
|
"role.delete",
|
||||||
|
"team.read",
|
||||||
|
"team.invite",
|
||||||
|
"organization.read",
|
||||||
|
"organization.listMembers",
|
||||||
|
// no wildcards here
|
||||||
|
];
|
||||||
|
|
||||||
|
validPermissions.forEach((permission) => {
|
||||||
|
expect(validator.validate(permission)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for invalid permission strings", () => {
|
||||||
|
const invalidPermissions = [
|
||||||
|
"invalid", // no dot
|
||||||
|
"invalid.", // no action
|
||||||
|
".invalid", // no resource
|
||||||
|
"invalid.action", // invalid resource
|
||||||
|
"eventType.invalid", // invalid action
|
||||||
|
"event-type.read", // wrong format (should be eventType)
|
||||||
|
"", // empty string
|
||||||
|
"eventType..read", // double dot
|
||||||
|
"eventType.read.extra", // too many parts
|
||||||
|
];
|
||||||
|
|
||||||
|
invalidPermissions.forEach((permission) => {
|
||||||
|
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for wildcard permission strings", () => {
|
||||||
|
const wildcardPermissions = ["*", "*.read", "eventType.*"];
|
||||||
|
|
||||||
|
wildcardPermissions.forEach((permission) => {
|
||||||
|
expect(() => validator.validate(permission)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultMessage", () => {
|
||||||
|
it("should return appropriate error message", () => {
|
||||||
|
const message = validator.defaultMessage();
|
||||||
|
expect(message).toContain("Permission must be a valid permission string");
|
||||||
|
expect(message).toContain("resource.action");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import type { ValidatorConstraintInterface } from "class-validator";
|
||||||
|
import { ValidatorConstraint } from "class-validator";
|
||||||
|
|
||||||
|
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
|
||||||
|
import { Scope } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: "orgPermissionStringValidator", async: false })
|
||||||
|
export class OrgPermissionStringValidator implements ValidatorConstraintInterface {
|
||||||
|
validate(permission: string) {
|
||||||
|
const isValid = isValidPermissionStringForScope(permission, Scope.Organization);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage() {
|
||||||
|
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
|
||||||
|
}
|
||||||
|
}
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
|
||||||
|
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
|
||||||
|
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 { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.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 { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { RoleService } from "@calcom/platform-libraries/pbac";
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
import type { Team, User } from "@calcom/prisma/client";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
describe("Organizations Roles Permissions Endpoints", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let featuresRepositoryFixture: FeaturesRepositoryFixture;
|
||||||
|
let roleService: RoleService;
|
||||||
|
|
||||||
|
let pbacOrgUserWithRolePermission: User;
|
||||||
|
let pbacOrgUserWithRolePermissionApiKey: string;
|
||||||
|
|
||||||
|
let pbacEnabledOrganization: Team;
|
||||||
|
|
||||||
|
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
|
||||||
|
roleService = new RoleService();
|
||||||
|
|
||||||
|
// Create PBAC org
|
||||||
|
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
|
||||||
|
name: `pbac-org-role-perm-test-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
|
||||||
|
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
|
||||||
|
|
||||||
|
// Create user + membership in org
|
||||||
|
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
|
||||||
|
email: pbacUserEmail,
|
||||||
|
username: pbacUserEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const membership = await membershipRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
|
||||||
|
team: { connect: { id: pbacEnabledOrganization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a role that allows role.read and role.update, assign to user
|
||||||
|
const managerRole = await roleService.createRole({
|
||||||
|
name: `role-manager-${randomString()}`,
|
||||||
|
teamId: pbacEnabledOrganization.id,
|
||||||
|
permissions: ["role.create", "role.read", "role.update"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
await roleService.assignRoleToMember(managerRole.id, membership.id);
|
||||||
|
|
||||||
|
// API key for user
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
|
||||||
|
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Negative Tests", () => {
|
||||||
|
it("rejects invalid permission on add (400)", async () => {
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `neg-add-invalid-${randomString()}`,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const roleId = createRes.body.data.id as string;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: ["invalid"] });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid permission on replace (400)", async () => {
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `neg-put-invalid-${randomString()}`,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const roleId = createRes.body.data.id as string;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: ["invalid"] });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid permission on delete single (400)", async () => {
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `neg-delone-invalid-${randomString()}`,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const roleId = createRes.body.data.id as string;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${"invalid"}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid permission on bulk delete (400)", async () => {
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `neg-delmany-invalid-${randomString()}`,
|
||||||
|
permissions: ["booking.read", "eventType.create"],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const roleId = createRes.body.data.id as string;
|
||||||
|
|
||||||
|
const invalid = "invalid";
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.delete(
|
||||||
|
`/v2/organizations/${
|
||||||
|
pbacEnabledOrganization.id
|
||||||
|
}/roles/${roleId}/permissions?permissions=${encodeURIComponent(`booking.read,${invalid}`)}`
|
||||||
|
)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error.message).toBe(
|
||||||
|
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when modifying permissions for role not belonging to organization", async () => {
|
||||||
|
// Create foreign organization and role
|
||||||
|
const foreignOrg = await organizationsRepositoryFixture.create({
|
||||||
|
name: `foreign-org-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
const foreignRole = await roleService.createRole({
|
||||||
|
name: `foreign-role-${randomString()}`,
|
||||||
|
teamId: foreignOrg.id,
|
||||||
|
permissions: ["booking.read"],
|
||||||
|
type: "CUSTOM",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: ["eventType.read"] });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
|
||||||
|
await organizationsRepositoryFixture.delete(foreignOrg.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when modifying permissions of a default (system) role not belonging to organization", async () => {
|
||||||
|
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: ["eventType.read"] });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists permissions for a role (GET /)", async () => {
|
||||||
|
const initialPermissions = ["booking.read"] as const;
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `perm-target-role-list-${randomString()}`,
|
||||||
|
permissions: [...initialPermissions],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||||
|
const roleId = responseBody.data.id;
|
||||||
|
|
||||||
|
const listRes = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(listRes.body.data).toEqual(initialPermissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds permissions (POST /)", async () => {
|
||||||
|
const initialPermissions = ["booking.read"] as const;
|
||||||
|
const toAdd = ["eventType.create", "eventType.read"] as const;
|
||||||
|
const expected = [...initialPermissions, ...toAdd] as string[];
|
||||||
|
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `perm-target-role-add-${randomString()}`,
|
||||||
|
permissions: [...initialPermissions],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||||
|
const roleId = responseBody.data.id;
|
||||||
|
|
||||||
|
const addRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: toAdd })
|
||||||
|
.expect(200);
|
||||||
|
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(addRes.body.data).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk removes permissions via query (DELETE /)", async () => {
|
||||||
|
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
|
||||||
|
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
|
||||||
|
const expected: PermissionString[] = ["booking.read"];
|
||||||
|
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `perm-target-role-delmany-${randomString()}`,
|
||||||
|
permissions: initialPermissions,
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||||
|
const roleId = responseBody.data.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(
|
||||||
|
`/v2/organizations/${
|
||||||
|
pbacEnabledOrganization.id
|
||||||
|
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
|
||||||
|
)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const listRes = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(listRes.body.data).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces all permissions (PUT /)", async () => {
|
||||||
|
const initialPermissions = ["booking.read"] as const;
|
||||||
|
const replacement = ["booking.read", "eventType.update"] as const;
|
||||||
|
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `perm-target-role-put-${randomString()}`,
|
||||||
|
permissions: [...initialPermissions],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||||
|
const roleId = responseBody.data.id;
|
||||||
|
|
||||||
|
const putRes = await request(app.getHttpServer())
|
||||||
|
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send({ permissions: replacement })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(putRes.body.data).toEqual(replacement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a single permission (DELETE /:permission)", async () => {
|
||||||
|
const initialPermissions = ["booking.read", "eventType.update"] as const;
|
||||||
|
const toRemove = "eventType.update" as const;
|
||||||
|
const expected = ["booking.read"] as const;
|
||||||
|
|
||||||
|
const baseRoleInput: CreateTeamRoleInput = {
|
||||||
|
name: `perm-target-role-delone-${randomString()}`,
|
||||||
|
permissions: [...initialPermissions],
|
||||||
|
};
|
||||||
|
const createRes = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.send(baseRoleInput)
|
||||||
|
.expect(201);
|
||||||
|
const responseBody: CreateTeamRoleOutput = createRes.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
|
||||||
|
const roleId = responseBody.data.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${toRemove}`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const listRes = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
|
||||||
|
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(listRes.body.data).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
// Clean up feature flag from organization
|
||||||
|
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
|
||||||
|
|
||||||
|
// Clean up org
|
||||||
|
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
|
||||||
|
|
||||||
|
// Clean up user
|
||||||
|
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
|
||||||
|
|
||||||
|
// Clean up feature definition
|
||||||
|
try {
|
||||||
|
await featuresRepositoryFixture.deleteBySlug("pbac");
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import {
|
||||||
|
OPTIONAL_API_KEY_HEADER,
|
||||||
|
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||||
|
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||||
|
} from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.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 { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { CreateOrgRolePermissionsInput } from "@/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input";
|
||||||
|
import { DeleteOrgRolePermissionsQuery } from "@/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query";
|
||||||
|
import { GetOrgRolePermissionsOutput } from "@/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output";
|
||||||
|
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/roles/:roleId/permissions",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@DocsTags("Orgs / Roles / Permissions")
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
|
export class OrganizationsRolesPermissionsController {
|
||||||
|
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.update"])
|
||||||
|
@Post("/")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Add permissions to an organization role (single or batch)" })
|
||||||
|
async addPermissions(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string,
|
||||||
|
@Body() body: CreateOrgRolePermissionsInput
|
||||||
|
): Promise<GetOrgRolePermissionsOutput> {
|
||||||
|
const permissions = await this.rolePermissionsService.addRolePermissions(orgId, roleId, body.permissions);
|
||||||
|
return { status: SUCCESS_STATUS, data: permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.read"])
|
||||||
|
@Get("/")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "List permissions for an organization role" })
|
||||||
|
async listPermissions(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string
|
||||||
|
): Promise<GetOrgRolePermissionsOutput> {
|
||||||
|
const permissions = await this.rolePermissionsService.getRolePermissions(orgId, roleId);
|
||||||
|
return { status: SUCCESS_STATUS, data: permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.update"])
|
||||||
|
@Put("/")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Replace all permissions for an organization role" })
|
||||||
|
async setPermissions(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string,
|
||||||
|
@Body() body: CreateOrgRolePermissionsInput
|
||||||
|
): Promise<GetOrgRolePermissionsOutput> {
|
||||||
|
const permissions = await this.rolePermissionsService.setRolePermissions(
|
||||||
|
orgId,
|
||||||
|
roleId,
|
||||||
|
body.permissions || []
|
||||||
|
);
|
||||||
|
return { status: SUCCESS_STATUS, data: permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.update"])
|
||||||
|
@Delete("/:permission")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: "Remove a permission from an organization role" })
|
||||||
|
async removePermission(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string,
|
||||||
|
@Param("permission") permission: PermissionString
|
||||||
|
): Promise<void> {
|
||||||
|
await this.rolePermissionsService.removeRolePermission(orgId, roleId, permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Pbac(["role.update"])
|
||||||
|
@Delete("/")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: "Remove multiple permissions from an organization role" })
|
||||||
|
async removePermissions(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("roleId") roleId: string,
|
||||||
|
@Query() query: DeleteOrgRolePermissionsQuery
|
||||||
|
): Promise<void> {
|
||||||
|
await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsString } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class GetOrgRolePermissionsOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS })
|
||||||
|
status!: typeof SUCCESS_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
data!: string[];
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
|
||||||
|
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import type { Role } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationsRolesOutputService {
|
||||||
|
getOrganizationRoleOutput(role: Role): OrgRoleOutput {
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
color: role.color || null,
|
||||||
|
description: role.description || null,
|
||||||
|
organizationId: role.teamId || null,
|
||||||
|
type: role.type,
|
||||||
|
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
|
||||||
|
createdAt: role.createdAt.toISOString(),
|
||||||
|
updatedAt: role.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrganizationRolesOutput(roles: Role[]): TeamRoleOutput[] {
|
||||||
|
return roles.map((role) => this.getOrganizationRoleOutput(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -73,6 +73,16 @@ export class GetOrganizationsTeamsBookingsInput_2024_08_13 {
|
|||||||
})
|
})
|
||||||
attendeeName?: string;
|
attendeeName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
description: "Filter bookings by the booking Uid.",
|
||||||
|
example: "2NtaeaVcKfpmSZ4CthFdfk",
|
||||||
|
})
|
||||||
|
bookingUid?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => {
|
@Transform(({ value }) => {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|||||||
+25
-2
@@ -60,6 +60,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
|||||||
let teamUser2: User;
|
let teamUser2: User;
|
||||||
|
|
||||||
let team1EventTypeId: number;
|
let team1EventTypeId: number;
|
||||||
|
let bookingUid: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -237,6 +238,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
|||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
expect(data.id).toBeDefined();
|
expect(data.id).toBeDefined();
|
||||||
expect(data.uid).toBeDefined();
|
expect(data.uid).toBeDefined();
|
||||||
|
bookingUid = data.uid;
|
||||||
expect(data.hosts.length).toEqual(1);
|
expect(data.hosts.length).toEqual(1);
|
||||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||||
expect(data.status).toEqual("accepted");
|
expect(data.status).toEqual("accepted");
|
||||||
@@ -308,6 +310,27 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should get bookings by teamId and bookingUid", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?bookingUid=${bookingUid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
const data: (
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
)[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(1);
|
||||||
|
expect(data[0].uid).toEqual(bookingUid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should not get bookings by teamId and non existing eventTypeId", async () => {
|
it("should not get bookings by teamId and non existing eventTypeId", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
|
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
|
||||||
@@ -336,8 +359,8 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
+203
@@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
|||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-accept tests
|
||||||
|
describe("auto-accept based on email domain", () => {
|
||||||
|
let orgWithAutoAccept: Team;
|
||||||
|
let subteamWithAutoAccept: Team;
|
||||||
|
let userWithMatchingEmail: User;
|
||||||
|
let userWithUppercaseEmail: User;
|
||||||
|
let userWithMatchingEmailForOverride: User;
|
||||||
|
let userWithNonMatchingEmail: User;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create org with auto-accept settings
|
||||||
|
orgWithAutoAccept = await organizationsRepositoryFixture.create({
|
||||||
|
name: `auto-accept-org-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update organizationSettings with orgAutoAcceptEmail
|
||||||
|
await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, {
|
||||||
|
orgAutoAcceptEmail: "acme.com",
|
||||||
|
isOrganizationVerified: true,
|
||||||
|
isOrganizationConfigured: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create subteam
|
||||||
|
subteamWithAutoAccept = await teamsRepositoryFixture.create({
|
||||||
|
name: `auto-accept-subteam-${randomString()}`,
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create event type with assignAllTeamMembers
|
||||||
|
await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: { connect: { id: subteamWithAutoAccept.id } },
|
||||||
|
title: "Auto Accept Event Type",
|
||||||
|
slug: "auto-accept-event-type",
|
||||||
|
length: 30,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create users
|
||||||
|
userWithMatchingEmail = await userRepositoryFixture.create({
|
||||||
|
email: `alice@acme.com`,
|
||||||
|
username: `alice-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
userWithUppercaseEmail = await userRepositoryFixture.create({
|
||||||
|
email: `bob@ACME.COM`,
|
||||||
|
username: `bob-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
|
||||||
|
email: `david@acme.com`,
|
||||||
|
username: `david-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
userWithNonMatchingEmail = await userRepositoryFixture.create({
|
||||||
|
email: `charlie@external.com`,
|
||||||
|
username: `charlie-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add users to org
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
user: { connect: { id: userWithMatchingEmail.id } },
|
||||||
|
team: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||||
|
team: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||||
|
team: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||||
|
team: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create profiles for users
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${userWithMatchingEmail.id}`,
|
||||||
|
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
|
||||||
|
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
user: { connect: { id: userWithMatchingEmail.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${userWithUppercaseEmail.id}`,
|
||||||
|
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
|
||||||
|
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${userWithMatchingEmailForOverride.id}`,
|
||||||
|
username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
|
||||||
|
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${userWithNonMatchingEmail.id}`,
|
||||||
|
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
|
||||||
|
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make user an admin of the org for API access
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
accepted: true,
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: orgWithAutoAccept.id } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should auto-accept when email matches orgAutoAcceptEmail", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||||
|
.send({
|
||||||
|
userId: userWithMatchingEmail.id,
|
||||||
|
role: "MEMBER",
|
||||||
|
} satisfies CreateOrgTeamMembershipDto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||||
|
expect(responseBody.data.accepted).toBe(true);
|
||||||
|
|
||||||
|
// Verify EventTypes assignment
|
||||||
|
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(
|
||||||
|
subteamWithAutoAccept.id
|
||||||
|
);
|
||||||
|
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
|
||||||
|
expect(eventTypeWithAssignAll).toBeTruthy();
|
||||||
|
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
|
||||||
|
expect(userIsHost).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle case-insensitive email domain matching", async () => {
|
||||||
|
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||||
|
.send({
|
||||||
|
userId: userWithUppercaseEmail.id,
|
||||||
|
role: "MEMBER",
|
||||||
|
} satisfies CreateOrgTeamMembershipDto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||||
|
expect(responseBody.data.accepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||||
|
.send({
|
||||||
|
userId: userWithMatchingEmailForOverride.id,
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: false,
|
||||||
|
} satisfies CreateOrgTeamMembershipDto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||||
|
// Should override to true because email matches
|
||||||
|
expect(responseBody.data.accepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||||
|
.send({
|
||||||
|
userId: userWithNonMatchingEmail.id,
|
||||||
|
role: "MEMBER",
|
||||||
|
} satisfies CreateOrgTeamMembershipDto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||||
|
expect(responseBody.data.accepted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
|
||||||
|
await organizationsRepositoryFixture.delete(orgWithAutoAccept.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||||
|
|||||||
+22
-3
@@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
|||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
|
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||||
@@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
||||||
private readonly organizationsRepository: OrganizationsRepository
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
) {}
|
private readonly orgMembershipService: OrganizationMembershipService
|
||||||
|
) { }
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({ summary: "Get all memberships" })
|
@ApiOperation({ summary: "Get all memberships" })
|
||||||
@@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service
|
||||||
|
// See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
throw new UnprocessableEntityException("User is not part of the Organization");
|
throw new UnprocessableEntityException("User is not part of the Organization");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
|
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
|
||||||
|
organizationId: orgId,
|
||||||
|
userEmail: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ALWAYS override when email matches - prevents pending memberships
|
||||||
|
// Remember organizations expect added team member to automatically start receiving bookings for the team event
|
||||||
|
const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false);
|
||||||
|
|
||||||
|
const membershipData = { ...data, accepted: acceptedStatus };
|
||||||
|
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(
|
||||||
|
teamId,
|
||||||
|
membershipData
|
||||||
|
);
|
||||||
|
|
||||||
if (membership.accepted) {
|
if (membership.accepted) {
|
||||||
try {
|
try {
|
||||||
await updateNewTeamMemberEventTypes(user.id, teamId);
|
await updateNewTeamMemberEventTypes(user.id, teamId);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/platform-libraries/pbac";
|
||||||
|
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
|
||||||
|
|
||||||
|
import { TeamPermissionStringValidator } from "../permissions/inputs/validators/team-permission-string.validator";
|
||||||
|
|
||||||
|
export const teamPermissionEnum = [...getAllPermissionStringsForScope(Scope.Team)] as const;
|
||||||
|
|
||||||
|
export class BaseTeamRoleInput {
|
||||||
|
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Description of the role" })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
|
||||||
|
enum: teamPermissionEnum,
|
||||||
|
isArray: true,
|
||||||
|
example: ["eventType.read", "eventType.create", "booking.read"],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(TeamPermissionStringValidator, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
permissions?: PermissionString[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||||
|
|
||||||
|
export class CreateTeamRoleInput extends BaseTeamRoleInput {
|
||||||
|
@ApiProperty({ description: "Name of the role", minLength: 1 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsString, IsOptional, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
import { BaseTeamRoleInput } from "./base-team-role.input";
|
||||||
|
|
||||||
|
export class UpdateTeamRoleInput extends BaseTeamRoleInput {
|
||||||
|
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user