Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
31da30b982 | ||
|
|
d149f8385e | ||
|
|
ea5a0bd5f0 | ||
|
|
67c9fd92e1 | ||
|
|
50be997753 | ||
|
|
f00c14d0c1 | ||
|
|
8d4dd0241a | ||
|
|
90f97c3c92 | ||
|
|
1407886612 | ||
|
|
139e47fe3b | ||
|
|
9d4ecf2baa | ||
|
|
9d1cf3e70e | ||
|
|
6beb279a27 | ||
|
|
69e4b12f52 | ||
|
|
b8098ed3ab | ||
|
|
db55b11034 | ||
|
|
2273b7ea24 | ||
|
|
0a90212980 | ||
|
|
2efcfab715 | ||
|
|
3c1dfe8b74 | ||
|
|
e47ddf9835 | ||
|
|
08c62b554e | ||
|
|
b0b94b91fe | ||
|
|
af61b6d341 | ||
|
|
28693aba96 | ||
|
|
2dc0e2a6f2 | ||
|
|
6cbe1c1cf1 | ||
|
|
1c2da58d2a | ||
|
|
7bfdaa3d64 | ||
|
|
c38d2afcf5 | ||
|
|
807848cce2 | ||
|
|
e9d409c0d5 | ||
|
|
0c43d60039 | ||
|
|
1173822344 | ||
|
|
a934cec51a | ||
|
|
257acc3f71 | ||
|
|
91063c41f8 | ||
|
|
1e1298fdbc | ||
|
|
95120e1ff4 | ||
|
|
ee00f0d52c | ||
|
|
246abe0dd9 | ||
|
|
14e845ea1f | ||
|
|
8ba65c4450 | ||
|
|
aff494f21c | ||
|
|
18ad7e348e | ||
|
|
78f3d76ae2 | ||
|
|
6f9e04c6d9 | ||
|
|
fdb0e5d004 | ||
|
|
268a142370 | ||
|
|
29a3892669 | ||
|
|
0223027958 | ||
|
|
d7df154059 | ||
|
|
51d78e6f9d | ||
|
|
d5894d9fbc | ||
|
|
8b0539028e | ||
|
|
c7600d8ead | ||
|
|
6f5b8ead75 | ||
|
|
fc159fb9ea | ||
|
|
b3297f2843 | ||
|
|
1dade81299 | ||
|
|
b2839830c8 | ||
|
|
fa884e8516 | ||
|
|
3418a25a85 | ||
|
|
89cc175d73 | ||
|
|
c7c8564b63 | ||
|
|
b620581d7a | ||
|
|
c0530cc91c | ||
|
|
f738b3bc0a | ||
|
|
0853b4ca35 | ||
|
|
8be05b1dc5 | ||
|
|
71515ad17c | ||
|
|
66234ffba9 | ||
|
|
66c5f810ea | ||
|
|
81d9d84b4a | ||
|
|
6ddc666827 | ||
|
|
01346abb85 | ||
|
|
3d9650c841 | ||
|
|
f8ede75dd8 | ||
|
|
435587f043 | ||
|
|
e5abe93940 | ||
|
|
c3a5158a53 | ||
|
|
a9e14b9816 | ||
|
|
e207f40611 | ||
|
|
4ca2076c72 | ||
|
|
959b803d84 | ||
|
|
7f6136004c | ||
|
|
bb79368b2d | ||
|
|
70825dd138 | ||
|
|
05c399db9c | ||
|
|
57776b829b | ||
|
|
7fd28c39f3 | ||
|
|
ff17aa79fc | ||
|
|
578be32936 | ||
|
|
b183239ff5 | ||
|
|
013e614397 | ||
|
|
6843ced3db | ||
|
|
7318015a6e | ||
|
|
fc4d0a6aa3 | ||
|
|
8e0cdf3671 | ||
|
|
fcd9480f9f | ||
|
|
09ddbe886f | ||
|
|
4ac8a51bc2 | ||
|
|
82951cd04e |
@@ -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,36 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
**/.next
|
||||
dist
|
||||
**/dist
|
||||
build
|
||||
**/build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.github
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
**/coverage
|
||||
test-results
|
||||
**/test-results
|
||||
.turbo
|
||||
**/.turbo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Examples and docs
|
||||
docs
|
||||
@@ -486,3 +486,6 @@ CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
SALESFORCE_GRAPHQL_DELAY_MS=500
|
||||
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
|
||||
SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
||||
|
||||
# Force Node.js to use UTC as process timezone
|
||||
TZ=UTC
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
name: "Docker Build and Test"
|
||||
description: "Reusable action to build and test Docker images for Cal.com"
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: "Target platform (linux/amd64 or arm64)"
|
||||
required: true
|
||||
platform-suffix:
|
||||
description: "Suffix to add to image tags (e.g., -arm)"
|
||||
required: false
|
||||
default: ""
|
||||
dockerhub-username:
|
||||
description: "Docker Hub username"
|
||||
required: true
|
||||
dockerhub-token:
|
||||
description: "Docker Hub token"
|
||||
required: true
|
||||
github-token:
|
||||
description: "GitHub token for GHCR"
|
||||
required: true
|
||||
postgres-user:
|
||||
description: "PostgreSQL user"
|
||||
required: true
|
||||
postgres-password:
|
||||
description: "PostgreSQL password"
|
||||
required: true
|
||||
postgres-db:
|
||||
description: "PostgreSQL database name"
|
||||
required: true
|
||||
database-host:
|
||||
description: "Database host"
|
||||
required: true
|
||||
push-image:
|
||||
description: "Whether to push the built image"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Log in to the Docker Hub registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-username }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
logout: true
|
||||
|
||||
- name: Log in to the Github Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/calendso/calendso
|
||||
docker.io/calcom/cal.com
|
||||
ghcr.io/calcom/cal.com
|
||||
flavor: |
|
||||
latest=${{ !github.event.release.prerelease }}
|
||||
suffix=${{ inputs.platform-suffix }}
|
||||
|
||||
- name: Copy env
|
||||
shell: bash
|
||||
run: |
|
||||
grep -o '^[^#]*' .env.example > .env
|
||||
cat .env >> $GITHUB_ENV
|
||||
echo "DATABASE_HOST=localhost:5432" >> $GITHUB_ENV
|
||||
eval $(sed -e '/^#/d' -e 's/^/export /' -e 's/$/;/' .env) ;
|
||||
|
||||
- name: Start database
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose up -d database
|
||||
|
||||
- name: Show database logs and container status
|
||||
shell: bash
|
||||
run: |
|
||||
echo "--- Container Status ---"
|
||||
docker compose ps database
|
||||
|
||||
echo "--- Container Logs ---"
|
||||
docker compose logs database
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=container:database
|
||||
buildkitd-flags: |
|
||||
--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host
|
||||
|
||||
- name: Build image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
load: true
|
||||
push: false
|
||||
platforms: ${{ inputs.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
|
||||
- name: Test runtime
|
||||
shell: bash
|
||||
run: |
|
||||
tags="${{ steps.meta.outputs.tags }}"
|
||||
IFS=',' read -ra ADDR <<< "$tags"
|
||||
tag=${ADDR[0]}
|
||||
|
||||
docker run --rm --network stack \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||
-e DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||
-e NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} \
|
||||
-e CALENDSO_ENCRYPTION_KEY=${{ env.CALENDSO_ENCRYPTION_KEY }} \
|
||||
$tag &
|
||||
|
||||
server_pid=$!
|
||||
|
||||
echo "Waiting for the server to start..."
|
||||
sleep 120
|
||||
|
||||
echo http://localhost:3000/auth/login
|
||||
|
||||
for i in {1..60}; do
|
||||
echo "Checking server health ($i/60)..."
|
||||
response=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/auth/login)
|
||||
echo "HTTP Status Code: $response"
|
||||
if [[ "$response" == "200" ]] || [[ "$response" == "307" ]]; then
|
||||
echo "Server is healthy"
|
||||
kill $server_pid
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Server health check failed"
|
||||
kill $server_pid
|
||||
exit 1
|
||||
env:
|
||||
NEXTAUTH_SECRET: "EI4qqDpcfdvf4A+0aQEEx8JjHxHSy4uWiZw/F32K+pA="
|
||||
CALENDSO_ENCRYPTION_KEY: "0zfLtY99wjeLnsM7qsa8xsT+Q0oSgnOL"
|
||||
|
||||
- name: Push image
|
||||
id: docker_push
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ inputs.push-image == 'true' && !github.event.release.prerelease }}
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: ${{ inputs.platform }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||
|
||||
- name: Image digest
|
||||
shell: bash
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
docker compose down
|
||||
@@ -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'
|
||||
@@ -6,23 +6,31 @@ on:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node and registry for OIDC
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.17.0"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
always-auth: false
|
||||
- name: Setup node, yarn and install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Install npm at version supporting OIDC
|
||||
run: npm install -g npm@11.5.1
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
@@ -33,8 +41,7 @@ jobs:
|
||||
title: "chore: version packages" # PR title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: -1
|
||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 14
|
||||
stale-pr-message: "This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active."
|
||||
close-pr-message: "This PR has been closed due to inactivity. Please feel free to reopen it if you'd like to continue the work."
|
||||
operations-per-run: 100
|
||||
|
||||
@@ -72,18 +72,22 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
|
||||
|
||||
- name: Generate Swagger
|
||||
working-directory: apps/api/v2
|
||||
run: yarn generate-swagger
|
||||
|
||||
- name: Check breaking changes
|
||||
uses: oasdiff/oasdiff-action/breaking@main
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: ERR
|
||||
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/work" -w /work \
|
||||
tufin/oasdiff:latest \
|
||||
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
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
EXIT_CODE=$?
|
||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
name: "Release Docker"
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
- published
|
||||
env:
|
||||
POSTGRES_USER: "unicorn_user"
|
||||
POSTGRES_PASSWORD: "magical_password"
|
||||
POSTGRES_DB: "calendso"
|
||||
DATABASE_HOST: "database:5432"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# in case manual trigger is needed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -12,11 +17,9 @@ on: # yamllint disable-line rule:truthy
|
||||
description: "v{Major}.{Minor}.{Patch}"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: "Remote Release"
|
||||
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
release-amd64:
|
||||
name: "Release AMD64"
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,25 +27,84 @@ jobs:
|
||||
- name: "Determine tag"
|
||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||
|
||||
- name: "Run remote release workflow"
|
||||
uses: "actions/github-script@v6"
|
||||
- name: Build and test Docker image
|
||||
uses: ./.github/actions/docker-build-and-test
|
||||
with:
|
||||
# Requires a personal access token with Actions Read and write permissions on calcom/docker.
|
||||
github-token: "${{ secrets.DOCKER_REPO_ACCESS_TOKEN }}"
|
||||
script: |
|
||||
try {
|
||||
const response = await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: 'docker',
|
||||
workflow_id: 'create-release.yaml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
"RELEASE_TAG": process.env.RELEASE_TAG
|
||||
},
|
||||
});
|
||||
platform: "linux/amd64"
|
||||
platform-suffix: ""
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
postgres-user: ${{ env.POSTGRES_USER }}
|
||||
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||
postgres-db: ${{ env.POSTGRES_DB }}
|
||||
database-host: ${{ env.DATABASE_HOST }}
|
||||
push-image: "true"
|
||||
|
||||
console.log(response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
core.setFailed(error.message);
|
||||
- name: Notify Slack on Success
|
||||
if: success()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (AMD64) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on Failure
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":red_circle: Workflow *${{ github.workflow }}* (AMD64) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
release-arm:
|
||||
name: "Release ARM"
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Determine tag"
|
||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||
|
||||
- name: Build and test Docker image
|
||||
uses: ./.github/actions/docker-build-and-test
|
||||
with:
|
||||
platform: "arm64"
|
||||
platform-suffix: "-arm"
|
||||
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
postgres-user: ${{ env.POSTGRES_USER }}
|
||||
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||
postgres-db: ${{ env.POSTGRES_DB }}
|
||||
database-host: ${{ env.DATABASE_HOST }}
|
||||
push-image: "true"
|
||||
|
||||
- name: Notify Slack on Success
|
||||
if: success()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (ARM) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on Failure
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": ":red_circle: Workflow *${{ github.workflow }}* (ARM) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
Vendored
Vendored
+91
@@ -0,0 +1,91 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20 AS builder
|
||||
|
||||
WORKDIR /calcom
|
||||
|
||||
## If we want to read any ENV variable from .env file, we need to first accept and pass it as an argument to the Dockerfile
|
||||
ARG NEXT_PUBLIC_LICENSE_CONSENT
|
||||
ARG NEXT_PUBLIC_WEBSITE_TERMS_URL
|
||||
ARG NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL
|
||||
ARG CALCOM_TELEMETRY_DISABLED
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET=secret
|
||||
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||
ARG MAX_OLD_SPACE_SIZE=4096
|
||||
ARG NEXT_PUBLIC_API_V2_URL
|
||||
ARG CSP_POLICY
|
||||
|
||||
## We need these variables as required by Next.js build to create rewrites
|
||||
ARG NEXT_PUBLIC_SINGLE_ORG_SLUG
|
||||
ARG ORGANIZATIONS_ENABLED
|
||||
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER \
|
||||
NEXT_PUBLIC_API_V2_URL=$NEXT_PUBLIC_API_V2_URL \
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=$NEXT_PUBLIC_LICENSE_CONSENT \
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL=$NEXT_PUBLIC_WEBSITE_TERMS_URL \
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=$NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL \
|
||||
CALCOM_TELEMETRY_DISABLED=$CALCOM_TELEMETRY_DISABLED \
|
||||
DATABASE_URL=$DATABASE_URL \
|
||||
DATABASE_DIRECT_URL=$DATABASE_URL \
|
||||
NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
|
||||
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=$NEXT_PUBLIC_SINGLE_ORG_SLUG \
|
||||
ORGANIZATIONS_ENABLED=$ORGANIZATIONS_ENABLED \
|
||||
NODE_OPTIONS=--max-old-space-size=${MAX_OLD_SPACE_SIZE} \
|
||||
BUILD_STANDALONE=true \
|
||||
CSP_POLICY=$CSP_POLICY
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json i18n.json ./
|
||||
COPY .yarn ./.yarn
|
||||
COPY apps/web ./apps/web
|
||||
COPY apps/api/v2 ./apps/api/v2
|
||||
COPY packages ./packages
|
||||
COPY tests ./tests
|
||||
|
||||
RUN yarn config set httpTimeout 1200000
|
||||
RUN npx turbo prune --scope=@calcom/web --scope=@calcom/trpc --docker
|
||||
RUN yarn install
|
||||
# Build and make embed servable from web/public/embed folder
|
||||
RUN yarn workspace @calcom/trpc run build
|
||||
RUN yarn --cwd packages/embeds/embed-core workspace @calcom/embed-core run build
|
||||
RUN yarn --cwd apps/web workspace @calcom/web run build
|
||||
RUN rm -rf node_modules/.cache .yarn/cache apps/web/.next/cache
|
||||
|
||||
FROM node:20 AS builder-two
|
||||
|
||||
WORKDIR /calcom
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json .yarnrc.yml turbo.json i18n.json ./
|
||||
COPY .yarn ./.yarn
|
||||
COPY --from=builder /calcom/yarn.lock ./yarn.lock
|
||||
COPY --from=builder /calcom/node_modules ./node_modules
|
||||
COPY --from=builder /calcom/packages ./packages
|
||||
COPY --from=builder /calcom/apps/web ./apps/web
|
||||
COPY --from=builder /calcom/packages/prisma/schema.prisma ./prisma/schema.prisma
|
||||
COPY scripts scripts
|
||||
RUN chmod +x scripts/*
|
||||
|
||||
# Save value used during this build stage. If NEXT_PUBLIC_WEBAPP_URL and BUILT_NEXT_PUBLIC_WEBAPP_URL differ at
|
||||
# run-time, then start.sh will find/replace static values again.
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||
|
||||
RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_WEBAPP_URL}
|
||||
|
||||
FROM node:20 AS runner
|
||||
|
||||
WORKDIR /calcom
|
||||
COPY --from=builder-two /calcom ./
|
||||
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=30s --retries=5 \
|
||||
CMD wget --spider http://localhost:3000 || exit 1
|
||||
|
||||
CMD ["/calcom/scripts/start.sh"]
|
||||
@@ -4,7 +4,7 @@
|
||||
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||
<h3 align="center">Cal.com</h3>
|
||||
|
||||
<p align="center">
|
||||
The open-source Calendly successor.
|
||||
@@ -175,6 +175,7 @@ yarn dx
|
||||
```
|
||||
|
||||
#### Development tip
|
||||
|
||||
1. Add `export NODE_OPTIONS=“--max-old-space-size=16384”` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
|
||||
|
||||
2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
||||
@@ -226,7 +227,7 @@ for Logger level to be set at info, for example.
|
||||
|
||||
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
|
||||
|
||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||

|
||||
|
||||
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
||||
@@ -375,15 +376,268 @@ Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1
|
||||
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal.com is an effort powered by people within the community.
|
||||
**Official support**: Our team will begin to officially support the Dockerfile and docker-compose resources in this
|
||||
repository.
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
|
||||
**Important**: Cal.com will **not** be supporting installations that use these Docker resources. While we provide and maintain the Docker configurations, support for Docker-based installations is the responsibility of the user.
|
||||
|
||||
The Docker configuration can be found [in our docker repository](https://github.com/calcom/docker).
|
||||
This image can be found on DockerHub at [https://hub.docker.com/r/calcom/cal.com](https://hub.docker.com/r/calcom/cal.com).
|
||||
|
||||
Issues with Docker? Find your answer or open a new discussion [here](https://github.com/calcom/docker/discussions) to ask the community.
|
||||
**Note for ARM Users**: Use the {version}-arm suffix for pulling images. Example: `docker pull calcom/cal.com:v5.6.19-arm`.
|
||||
|
||||
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
#### Requirements
|
||||
|
||||
Make sure you have `docker` & `docker compose` installed on the server / system. Both are installed by most docker utilities, including Docker Desktop and Rancher Desktop.
|
||||
|
||||
Note: `docker compose` without the hyphen is now the primary method of using docker-compose, per the Docker documentation.
|
||||
|
||||
#### (Most users) Running Cal.com with Docker Compose
|
||||
|
||||
If you are evaluating Cal.com or running with minimal to no modifications, this option is for you.
|
||||
|
||||
1. Clone calcom/cal.com
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
2. Change into the directory
|
||||
|
||||
```bash
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
3. Prepare your configuration: Rename `.env.example` to `.env` and then update `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Most configurations can be left as-is, but for configuration options see [Important Run-time variables](#important-run-time-variables) below.
|
||||
|
||||
**Push Notifications (VAPID Keys)**
|
||||
If you see an error like:
|
||||
|
||||
```
|
||||
Error: No key set vapidDetails.publicKey
|
||||
```
|
||||
|
||||
This means your environment variables for Web Push are missing.
|
||||
You must generate and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
|
||||
|
||||
Generate them with:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
Then update your `.env` file:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
|
||||
VAPID_PRIVATE_KEY=your_private_key_here
|
||||
```
|
||||
|
||||
Do **not** commit real keys to `.env.example` — only placeholders.
|
||||
|
||||
Update the appropriate values in your .env file, then proceed.
|
||||
|
||||
4. (optional) Pre-Pull the images by running the following command:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
This will use the default image locations as specified by `image:` in the docker-compose.yaml file.
|
||||
|
||||
Note: To aid with support, by default Scarf.sh is used as registry proxy for download metrics.
|
||||
|
||||
5. Start Cal.com via docker compose
|
||||
|
||||
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom studio
|
||||
```
|
||||
|
||||
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom
|
||||
```
|
||||
|
||||
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||
|
||||
6. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||
|
||||
#### Updating Cal.com
|
||||
|
||||
1. Stop the Cal.com stack
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
2. Pull the latest changes
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
3. Update env vars as necessary.
|
||||
4. Re-start the Cal.com stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### (Advanced users) Build and Run Cal.com
|
||||
|
||||
1. Clone calcom/docker.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/calcom/cal.com.git calcom-docker
|
||||
```
|
||||
|
||||
2. Change into the directory
|
||||
|
||||
```bash
|
||||
cd calcom-docker
|
||||
```
|
||||
|
||||
3. Update the calcom submodule. This project depends on the Cal.com source code, which is included here as a Git submodule. To make sure you get everything you need, update the submodule with the command below.
|
||||
|
||||
```bash
|
||||
git submodule update --remote --init
|
||||
```
|
||||
|
||||
Note: DO NOT use recursive submodule update, otherwise you will receive a git authentication error.
|
||||
|
||||
4. Rename `.env.example` to `.env` and then update `.env`
|
||||
|
||||
For configuration options see [Build-time variables](#build-time-variables) below. Update the appropriate values in your .env file, then proceed.
|
||||
|
||||
5. Build the Cal.com docker image:
|
||||
|
||||
Note: Due to application configuration requirements, an available database is currently required during the build process.
|
||||
|
||||
a) If hosting elsewhere, configure the `DATABASE_URL` in the .env file, and skip the next step
|
||||
|
||||
b) If a local or temporary database is required, start a local database via docker compose.
|
||||
|
||||
```bash
|
||||
docker compose up -d database
|
||||
```
|
||||
|
||||
6. Build Cal.com via docker compose (DOCKER_BUILDKIT=0 must be provided to allow a network bridge to be used at build time. This requirement will be removed in the future)
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=0 docker compose build calcom
|
||||
```
|
||||
|
||||
7. Start Cal.com via docker compose
|
||||
|
||||
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom studio
|
||||
```
|
||||
|
||||
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d calcom
|
||||
```
|
||||
|
||||
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||
|
||||
8. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||
|
||||
#### Configuration
|
||||
|
||||
##### Important Run-time variables
|
||||
|
||||
These variables must also be provided at runtime
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- |
|
||||
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||
| CALCOM_LICENSE_KEY | Enterprise License Key | optional | |
|
||||
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. NOTE: if this value differs from the value used at build-time, there will be a slight delay during container start (to update the statically built files). | optional | `http://localhost:3000` |
|
||||
| NEXTAUTH_URL | Location of the auth server. By default, this is the Cal.com docker instance itself. | optional | `{NEXT_PUBLIC_WEBAPP_URL}/api/auth` |
|
||||
| NEXTAUTH_SECRET | must match build variable | required | `secret` |
|
||||
| CALENDSO_ENCRYPTION_KEY | must match build variable | required | `secret` |
|
||||
|
||||
##### Build-time variables
|
||||
|
||||
If building the image yourself, these variables must be provided at the time of the docker build, and can be provided by updating the .env file. Currently, if you require changes to these variables, you must follow the instructions to build and publish your own image.
|
||||
|
||||
Updating these variables is not required for evaluation, but is required for running in production. Instructions for generating variables can be found in the [Cal.com instructions](https://github.com/calcom/cal.com)
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
|
||||
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||
| MAX_OLD_SPACE_SIZE | Needed for Nodejs/NPM build options | required | 4096 |
|
||||
| NEXT_PUBLIC_LICENSE_CONSENT | license consent - true/false | required | |
|
||||
| NEXTAUTH_SECRET | Cookie encryption key | required | `secret` |
|
||||
| CALENDSO_ENCRYPTION_KEY | Authentication encryption key | required | `secret` |
|
||||
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files | optional | `http://localhost:3000` |
|
||||
| NEXT_PUBLIC_WEBSITE_TERMS_URL | custom URL for terms and conditions website | optional | `https://cal.com/terms` |
|
||||
| NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL | custom URL for privacy policy website | optional | `https://cal.com/privacy` |
|
||||
| NEXT_PUBLIC_API_V2_URL | URL for the v2 API, only required for custom integrations or custom booking experiences using [Cal.com Platform](https://cal.com/platform) | optional | |
|
||||
| CALCOM_TELEMETRY_DISABLED | Allow Cal.com to collect anonymous usage data (set to `1` to disable) | optional | |
|
||||
| NEXT_PUBLIC_SINGLE_ORG_SLUG | Required if ORGANIZATIONS_ENABLED is true | optional | |
|
||||
| ORGANIZATIONS_ENABLED | Used for Enterprise or Organizations plan | optional | |
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
##### SSL edge termination
|
||||
|
||||
If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
|
||||
|
||||
##### Failed to commit changes: Invalid 'prisma.user.create()'
|
||||
|
||||
Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
|
||||
|
||||
##### CLIENT_FETCH_ERROR
|
||||
|
||||
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefor needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
|
||||
|
||||
```
|
||||
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
|
||||
docker-calcom-1 | @calcom/web:start: https://next-auth.js.org/errors#client_fetch_error request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost {
|
||||
docker-calcom-1 | @calcom/web:start: error: {
|
||||
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost',
|
||||
docker-calcom-1 | @calcom/web:start: stack: 'FetchError: request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.<anonymous> (/calcom/node_modules/next/dist/compiled/node-fetch/index.js:1:65756)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:events:513:28)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:domain:489:12)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.socketErrorListener (node:_http_client:494:9)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:events:513:28)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:domain:489:12)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at emitErrorNT (node:internal/streams/destroy:157:8)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at emitErrorCloseNT (node:internal/streams/destroy:122:3)\n' +
|
||||
docker-calcom-1 | @calcom/web:start: ' at processTicksAndRejections (node:internal/process/task_queues:83:21)',
|
||||
docker-calcom-1 | @calcom/web:start: name: 'FetchError'
|
||||
docker-calcom-1 | @calcom/web:start: },
|
||||
docker-calcom-1 | @calcom/web:start: url: 'http://testing.localhost:3000/api/auth/session',
|
||||
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost'
|
||||
docker-calcom-1 | @calcom/web:start: }
|
||||
```
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=81cda9f7-a102-453b-ac01-51c35650bd70" />
|
||||
|
||||
### Railway
|
||||
|
||||
@@ -425,7 +679,7 @@ See the [roadmap project](https://cal.com/roadmap) for a list of proposed featur
|
||||
|
||||
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||
|
||||
| | AGPLv3 | EE |
|
||||
@@ -497,8 +751,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||
|
||||
## Single Org Mode
|
||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||
|
||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -655,6 +909,7 @@ following
|
||||
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||
|
||||
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||
|
||||
1. Follow semantic versioning when using changesets.
|
||||
2. Mark breaking changes using `❗️Breaking change`
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: { username: true, id: true },
|
||||
select: { username: true, id: true, timeZone: true },
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
|
||||
@@ -19,7 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
team: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
isAdminAPIEnabled: true,
|
||||
is: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, beforeAll } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { handler } from "../../../pages/api/bookings/_get";
|
||||
|
||||
@@ -16,9 +16,14 @@ const DefaultPagination = {
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
describe("GET /api/bookings", async () => {
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
describe("GET /api/bookings", () => {
|
||||
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
|
||||
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
});
|
||||
|
||||
it("Does not return bookings of other users when user has no permission", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
@@ -193,6 +198,22 @@ describe("GET /api/bookings", async () => {
|
||||
describe("Expand feature to add relational data in return payload", () => {
|
||||
it("Returns only team data when expand=team is set", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
// Find a team booking and a non-team booking from seed data
|
||||
const team1 = await prisma.team.findFirst({ where: { slug: "team1" } });
|
||||
const teamEventType = await prisma.eventType.findFirst({
|
||||
where: { teamId: team1?.id },
|
||||
include: { bookings: true },
|
||||
});
|
||||
const teamBooking = teamEventType?.bookings[0];
|
||||
|
||||
const nonTeamBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventType: { teamId: null },
|
||||
userId: proUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
@@ -205,14 +226,23 @@ describe("GET /api/bookings", async () => {
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
|
||||
const responseData = await handler(req);
|
||||
console.log("bookings=>", responseData.bookings);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||
if (booking.id === 19) {
|
||||
// The team field can be either null or undefined due to nullish() in the schema
|
||||
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||
|
||||
// Verify team booking has team data
|
||||
if (teamBooking) {
|
||||
const returnedTeamBooking = responseData.bookings.find((b) => b.id === teamBooking.id);
|
||||
expect(returnedTeamBooking?.eventType?.team?.slug).toBe("team1");
|
||||
}
|
||||
|
||||
// Verify non-team booking has null/undefined team
|
||||
if (nonTeamBooking) {
|
||||
const returnedNonTeamBooking = responseData.bookings.find((b) => b.id === nonTeamBooking.id);
|
||||
if (returnedNonTeamBooking) {
|
||||
expect(
|
||||
returnedNonTeamBooking.eventType?.team === null ||
|
||||
returnedNonTeamBooking.eventType?.team === undefined
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,11 +348,6 @@ describe("GET /api/bookings", async () => {
|
||||
|
||||
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
|
||||
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||
where: { userId: testUser.id },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
|
||||
@@ -16,14 +16,8 @@ ENV DATABASE_URL=${DATABASE_URL}
|
||||
COPY . .
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# Build prisma schema and make sure that it is linked to v2 node_modules
|
||||
RUN yarn workspace @calcom/api-v2 run generate-schemas
|
||||
RUN rm -rf apps/api/v2/node_modules
|
||||
RUN yarn install
|
||||
|
||||
RUN yarn workspace @calcom/platform-libraries run build
|
||||
|
||||
RUN yarn workspace @calcom/api-v2 run build
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
@@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaEventTypeRepository,
|
||||
PrismaTeamRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_04_15],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
|
||||
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
@@ -21,6 +22,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingResponse } from "@calcom/platform-libraries";
|
||||
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
@@ -40,6 +42,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
let recEventTypeId: number;
|
||||
|
||||
let createdBooking: RegularBookingCreateResult;
|
||||
|
||||
@@ -81,6 +84,18 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const recEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||
slug: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: 4, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recEventTypeId = recEventType.id;
|
||||
|
||||
eventTypeId = event.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -404,6 +419,210 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create recurring bookings", async () => {
|
||||
const bookingEventTypeId = recEventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
};
|
||||
|
||||
const body: CreateRecurringBookingInput_2024_04_15[] = [
|
||||
{
|
||||
start: "2040-06-21T09:30:00.000Z",
|
||||
end: "2040-06-21T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id",
|
||||
},
|
||||
{
|
||||
start: "2040-06-27T09:30:00.000Z",
|
||||
end: "2040-06-27T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-2",
|
||||
},
|
||||
{
|
||||
start: "2040-06-04T09:30:00.000Z",
|
||||
end: "2040-06-04T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-3",
|
||||
},
|
||||
{
|
||||
start: "2040-06-11T09:30:00.000Z",
|
||||
end: "2040-06-11T10:30:00.000Z",
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
recurringEventId: "test-recurring-event-id-4",
|
||||
},
|
||||
];
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings/recurring")
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<BookingResponse[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
responseBody.data.forEach((booking) => {
|
||||
expect(booking.userPrimaryEmail).toBeDefined();
|
||||
expect(booking.userPrimaryEmail).toEqual(userEmail);
|
||||
expect(booking.id).toBeDefined();
|
||||
expect(booking.uid).toBeDefined();
|
||||
expect(booking.eventTypeId).toEqual(bookingEventTypeId);
|
||||
expect(booking.user.timeZone).toEqual(bookingTimeZone);
|
||||
expect(booking.metadata).toEqual(bookingMetadata);
|
||||
expect(booking.responses).toEqual(bookingResponses);
|
||||
expect(booking.creationSource).toEqual("API_V2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type booking requires authentication", () => {
|
||||
let eventTypeRequiringAuthenticationId: number;
|
||||
let unauthorizedUser: User;
|
||||
let unauthorizedUserApiKeyString: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `event-type-requiring-authentication-${randomString()}`,
|
||||
slug: `event-type-requiring-authentication-${randomString()}`,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
bookingRequiresAuthentication: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
|
||||
|
||||
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
|
||||
unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: unauthorizedUserEmail,
|
||||
});
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
unauthorizedUserApiKeyString = keyString;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (unauthorizedUser) {
|
||||
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
|
||||
}
|
||||
});
|
||||
|
||||
it("can't be booked without credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T09:30:00.000Z",
|
||||
end: "2040-05-23T10:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
|
||||
});
|
||||
|
||||
it("can't be booked with unauthorized user credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T10:30:00.000Z",
|
||||
end: "2040-05-23T11:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("can be booked with event type owner credentials", async () => {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: "2040-05-23T11:30:00.000Z",
|
||||
end: "2040-05-23T12:30:00.000Z",
|
||||
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||
timeZone: "Europe/London",
|
||||
language: "en",
|
||||
metadata: {},
|
||||
hashedLink: "",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
|
||||
if (responseBody.data.id) {
|
||||
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -7,13 +7,20 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import {
|
||||
AuthOptionalUser,
|
||||
GetOptionalUser,
|
||||
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
@@ -38,6 +45,8 @@ import {
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
@@ -113,7 +122,9 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -166,8 +177,12 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/reschedule")
|
||||
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
|
||||
const booking = await getBookingForReschedule(bookingUid);
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
async getBookingForReschedule(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetOptionalUser() user: AuthOptionalUser
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const booking = await getBookingForReschedule(bookingUid, user?.id);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
@@ -190,6 +205,8 @@ export class BookingsController_2024_04_15 {
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid);
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
@@ -318,11 +335,9 @@ export class BookingsController_2024_04_15 {
|
||||
recurringEvent.recurringEventId = recurringEventId;
|
||||
}
|
||||
}
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
bookingData: body.map((booking) => ({ ...booking, creationSource: CreationSource.API_V2 })),
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
@@ -331,6 +346,7 @@ export class BookingsController_2024_04_15 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.body.noEmail,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -444,6 +460,76 @@ export class BookingsController_2024_04_15 {
|
||||
return oAuthClientParams.platformClientId;
|
||||
}
|
||||
|
||||
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({
|
||||
id: eventTypeId,
|
||||
});
|
||||
|
||||
if (!eventType?.bookingRequiresAuthentication) {
|
||||
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);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
"This event type requires authentication. Please provide valid credentials."
|
||||
);
|
||||
}
|
||||
|
||||
const isEventTypeOwner = eventType.userId === userId;
|
||||
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||
const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||
|
||||
let isOrgAdminOrOwner = false;
|
||||
if (eventType.team?.parentId) {
|
||||
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
|
||||
userId,
|
||||
teamId: eventType.team.parentId,
|
||||
});
|
||||
isOrgAdminOrOwner = !!orgTeam;
|
||||
} else if (eventType.team?.isOrganization) {
|
||||
isOrgAdminOrOwner = isTeamAdminOrOwner;
|
||||
}
|
||||
|
||||
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
|
||||
|
||||
if (!isAuthorized) {
|
||||
throw new ForbiddenException(
|
||||
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
|
||||
const res = { ...DEFAULT_PLATFORM_PARAMS };
|
||||
|
||||
@@ -503,7 +589,10 @@ export class BookingsController_2024_04_15 {
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
|
||||
async setPlatformAttendeesEmails(
|
||||
requestBody: { responses?: { email?: string; guests?: string[] } },
|
||||
oAuthClientId: string
|
||||
): Promise<void> {
|
||||
if (requestBody?.responses?.email) {
|
||||
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
|
||||
requestBody.responses.email,
|
||||
@@ -565,6 +654,9 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
if (err instanceof HttpException) {
|
||||
throw new HttpException(err.getResponse(), err.getStatus());
|
||||
}
|
||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||
throw new HttpException(error.message, 400);
|
||||
}
|
||||
|
||||
@@ -194,11 +194,6 @@ export class CreateBookingInput_2024_04_15 {
|
||||
@ApiHideProperty()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
_shouldServeCache?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
|
||||
@@ -21,7 +21,7 @@ export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
allRecurringDates?: Record<string, string>[];
|
||||
allRecurringDates?: { start: string; end: string | undefined }[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
@@ -15,6 +17,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
@@ -71,9 +74,11 @@ import { Module } from "@nestjs/common";
|
||||
OAuthClientRepository,
|
||||
OAuthClientUsersService,
|
||||
BookingsService_2024_08_13,
|
||||
BookingGuestsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
@@ -92,7 +97,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
})
|
||||
export class BookingsModule_2024_08_13 {}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Controller, Post, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings/:bookingUid/guests",
|
||||
version: VERSION_2024_08_13_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings / Guests")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||
example: VERSION_2024_08_13,
|
||||
required: true,
|
||||
})
|
||||
export class BookingGuestsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingGuestsService: BookingGuestsService_2024_08_13) {}
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Add guests to an existing booking",
|
||||
description: `Add one or more guests to an existing booking.
|
||||
|
||||
**Email Notifications:**
|
||||
When guests are added, the following notifications are sent (unless disabled by event type settings):
|
||||
|
||||
- **Organizer & Team Members:** Receive an "Add Guests" notification email informing them that new guests have been added to the booking.
|
||||
|
||||
- **New Guests:** Receive a "Scheduled Event" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.
|
||||
|
||||
- **Existing Guests:** Receive an "Add Guests" notification email informing them that additional guests have been added to the booking.
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||
`,
|
||||
})
|
||||
async addGuests(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: AddGuestsInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<AddGuestsOutput_2024_08_13> {
|
||||
const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@ export class BookingsController_2024_08_13 {
|
||||
If you are creating 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.
|
||||
|
||||
For event types that have SMS reminders workflow, you need to pass the attendee's phone number in the request body via \`attendee.phoneNumber\` (e.g., "+19876543210" in international format). This is an optional field, but becomes required when SMS reminders are enabled for the event type. For the complete attendee object structure, see the [attendee object](https://cal.com/docs/api-reference/v2/bookings/create-a-booking#body-attendee) documentation.
|
||||
|
||||
<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>
|
||||
`,
|
||||
})
|
||||
@@ -288,7 +290,7 @@ export class BookingsController_2024_08_13 {
|
||||
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
|
||||
],
|
||||
},
|
||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
|
||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.
|
||||
|
||||
If you are rescheduling 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.`,
|
||||
@@ -320,10 +322,13 @@ export class BookingsController_2024_08_13 {
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
|
||||
\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"}\`.
|
||||
|
||||
\nCancelling seated bookings:
|
||||
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.
|
||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. 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:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
@@ -343,7 +348,7 @@ export class BookingsController_2024_08_13 {
|
||||
],
|
||||
},
|
||||
description:
|
||||
"Accepts different types of cancel booking input: Cancel Booking (Option 1) or Cancel Seated Booking (Option 2)",
|
||||
"Accepts different types of cancel booking input: Cancel Booking (Option 1 which is for normal or recurring bookings) or Cancel Seated Booking (Option 2 which is for seated bookings)",
|
||||
})
|
||||
@ApiExtraModels(CancelBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13)
|
||||
async cancelBooking(
|
||||
@@ -400,9 +405,9 @@ export class BookingsController_2024_08_13 {
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -425,13 +430,13 @@ export class BookingsController_2024_08_13 {
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Param("userId") userId: number,
|
||||
@GetUser("id") reassignedById: number,
|
||||
@GetUser() reassignedByUser: ApiAuthGuardUser,
|
||||
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBookingToUser(
|
||||
bookingUid,
|
||||
userId,
|
||||
reassignedById,
|
||||
reassignedByUser,
|
||||
body
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
ERROR_STATUS,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeAddGuestsEmail,
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerAddGuestsEmail,
|
||||
} from "@calcom/platform-libraries/emails";
|
||||
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
const attendeeAddGuestsEmailSpy = jest
|
||||
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const organizerAddGuestsEmailSpy = jest
|
||||
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const attendeeScheduledEmailSpy = jest
|
||||
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
type TestUser = {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
organizer: TestUser;
|
||||
unrelatedUser: TestUser;
|
||||
guest: TestUser;
|
||||
eventTypeId: number;
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13 add guests", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
|
||||
let testSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-emails-2024-08-13-organization-${randomString()}`,
|
||||
});
|
||||
|
||||
await setupTestData();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupTestData() {
|
||||
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const unrelatedUserData = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const guestUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-guest-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||
unrelatedUserData.id,
|
||||
oAuthClient.id
|
||||
);
|
||||
const guestTokens = await tokensRepositoryFixture.createTokens(guestUser.id, oAuthClient.id);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-add-guests-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
testSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: {
|
||||
user: unrelatedUserData,
|
||||
accessToken: unrelatedUserTokens.accessToken,
|
||||
refreshToken: unrelatedUserTokens.refreshToken,
|
||||
},
|
||||
guest: {
|
||||
user: guestUser,
|
||||
accessToken: guestTokens.accessToken,
|
||||
refreshToken: guestTokens.refreshToken,
|
||||
},
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
areEmailsEnabled: emailsEnabled,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests", () => {
|
||||
beforeAll(async () => {
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: testSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("should return 401 when adding guests without authentication", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "unauthenticated.guest@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(addGuestsResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - Organizer", () => {
|
||||
it("should allow booking organizer to add multiple guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }, { email: "organizer.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["organizer.guest1@example.com", "organizer.guest2@example.com"],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 403 when unrelated user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [
|
||||
{ email: "non-organizer.guest1@example.com" },
|
||||
{ email: "non-organizer.guest2@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - guest", () => {
|
||||
it("should allow organizer to add a user as guest, then guest can add more guests", async () => {
|
||||
// Step 1: Organizer adds guest user as a guest
|
||||
const addguestBody = {
|
||||
guests: [{ email: testSetup.guest.user.email }],
|
||||
};
|
||||
|
||||
const addguestResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addguestBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addguestResponseBody: AddGuestsOutput_2024_08_13 = addguestResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addguestResponseBody, [testSetup.guest.user.email], true);
|
||||
|
||||
// Step 2: Now the guest can add their own guests
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "guest.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.guest.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addGuestsResponseBody, ["guest.guest1@example.com"], true);
|
||||
});
|
||||
|
||||
it("should return 403 when non-guest user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "non-guest.guest1@example.com" }, { email: "non-guest.guest2@example.com" }],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 when attempting to add duplicate guest email", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`);
|
||||
|
||||
expect(addGuestsResponse.status).toEqual(400);
|
||||
expect(addGuestsResponse.body.status).toEqual(ERROR_STATUS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests - Emails Disabled", () => {
|
||||
let emailsDisabledSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const oAuthClientDisabled = await createOAuthClient(organization.id, false);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-no-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClientDisabled.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(
|
||||
organizerUser.id,
|
||||
oAuthClientDisabled.id
|
||||
);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-no-emails-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: eventType.id,
|
||||
attendee: {
|
||||
name: "No Email User",
|
||||
email: "no.email.user@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/no-emails",
|
||||
bookingFieldsResponses: {
|
||||
customField: "noEmailValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "200",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
emailsDisabledSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: testSetup.unrelatedUser,
|
||||
guest: testSetup.guest,
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: createBookingResponseBody.data.uid,
|
||||
};
|
||||
});
|
||||
|
||||
it("should NOT send emails when adding guests (emails disabled)", async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "no-email.guest1@example.com" }, { email: "no-email.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsDisabledSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["no-email.guest1@example.com", "no-email.guest2@example.com"],
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.organizer.user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
emailsDisabledSetup.organizer.user.id,
|
||||
emailsDisabledSetup.organizer.user.email
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.guest.user.email);
|
||||
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
testSetup.organizer.user.id,
|
||||
testSetup.organizer.user.email
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||
}
|
||||
|
||||
function verifyAddGuestsResponse(
|
||||
responseBody: AddGuestsOutput_2024_08_13,
|
||||
expectedGuestEmails: string[],
|
||||
shouldEmailsBeSent: boolean
|
||||
): BookingOutput_2024_08_13 {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(responseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const bookingData = responseBody.data;
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
const guestEmails = bookingData.guests;
|
||||
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(guestEmails).toContain(email);
|
||||
});
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(bookingData.guests).toContain(email);
|
||||
});
|
||||
|
||||
if (shouldEmailsBeSent) {
|
||||
expect(attendeeAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(attendeeAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
return bookingData;
|
||||
}
|
||||
});
|
||||
+18
-17
@@ -12,6 +12,7 @@ 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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.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 {
|
||||
@@ -72,6 +72,7 @@ type EmailSetup = {
|
||||
team: Team;
|
||||
member1: User;
|
||||
member2: User;
|
||||
member1ApiKey: string;
|
||||
collectiveEventType: { id: number };
|
||||
roundRobinEventType: { id: number };
|
||||
};
|
||||
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
// Setup data for tests
|
||||
let emailsEnabledSetup: EmailSetup;
|
||||
let emailsDisabledSetup: EmailSetup;
|
||||
|
||||
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
|
||||
|
||||
// Utility function to check response data type
|
||||
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
|
||||
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
authEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
// Create a base organization for all tests
|
||||
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
emailsEnabledSetup = await setupTestEnvironment(true);
|
||||
emailsDisabledSetup = await setupTestEnvironment(false);
|
||||
|
||||
await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
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 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 {
|
||||
team,
|
||||
member1,
|
||||
member2,
|
||||
member1ApiKey,
|
||||
collectiveEventType: { id: collectiveEvent.id },
|
||||
roundRobinEventType: { id: roundRobinEvent.id },
|
||||
};
|
||||
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
: emailsDisabledSetup.member1.id;
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
// --- 4. Automatic Reassign ---
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
|
||||
const manualReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(manualReassignResponse.status).toBe(200);
|
||||
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
jest.clearAllMocks();
|
||||
const autoReassignResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(autoReassignResponse.status).toBe(200);
|
||||
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
afterAll(async () => {
|
||||
// Clean up database records
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(authEmail);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
|
||||
|
||||
+116
@@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
|
||||
let firstManagedUserEventTypeId: number;
|
||||
let eventTypeRequiresConfirmationId: number;
|
||||
let seatedEventTypeId: number;
|
||||
|
||||
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
||||
let orgAdminManagedUser: CreateManagedUserData;
|
||||
@@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||
});
|
||||
|
||||
it("should create a seated event type for first managed user", async () => {
|
||||
const seatedEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||
slug: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
firstManagedUser.user.id
|
||||
);
|
||||
seatedEventTypeId = seatedEventType.id;
|
||||
});
|
||||
|
||||
describe("bookings using original emails", () => {
|
||||
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("seated booking management by org admin", () => {
|
||||
let seatedBookingUid: string;
|
||||
|
||||
it("should create a seated booking with multiple attendees", async () => {
|
||||
const bodyOne: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: secondManagedUser.user.name!,
|
||||
email: secondManagedUser.user.email,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
},
|
||||
};
|
||||
|
||||
const responseOne = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bodyOne)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBodyForFirstAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseOne.body;
|
||||
expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBodyForFirstAttendee.data).toBeDefined();
|
||||
|
||||
const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13;
|
||||
seatedBookingUid = bookingDataForFirstAttendee.uid;
|
||||
expect(bookingDataForFirstAttendee.attendees).toBeDefined();
|
||||
expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0);
|
||||
|
||||
const bodyTwo: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUser.user.email,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
};
|
||||
|
||||
const responseTwo = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bodyTwo)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBodyForSecondAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseTwo.body;
|
||||
expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBodyForSecondAttendee.data).toBeDefined();
|
||||
|
||||
const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13;
|
||||
expect(bookingDataForSecondAttendee.attendees).toBeDefined();
|
||||
expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => {
|
||||
const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString();
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${seatedBookingUid}/reschedule`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.send({
|
||||
start: newStartTime,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<BookingOutput_2024_08_13> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.start).toEqual(newStartTime);
|
||||
|
||||
seatedBookingUid = bookingData.uid;
|
||||
});
|
||||
|
||||
it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${seatedBookingUid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.send({
|
||||
cancellationReason: "Org admin cancelled the booking",
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.status).toEqual("cancelled");
|
||||
expect(bookingData.uid).toEqual(seatedBookingUid);
|
||||
|
||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid);
|
||||
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type booking requires authentication", () => {
|
||||
let eventTypeRequiringAuthenticationId: number;
|
||||
|
||||
|
||||
+485
-10
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
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 { 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";
|
||||
@@ -11,6 +12,7 @@ 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 { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
@@ -21,7 +23,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.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 } from "@calcom/platform-types";
|
||||
@@ -44,23 +45,34 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||
const teamUserEmail3 = `reassign-bookings-2024-08-13-user3-${randomString()}@api.com`;
|
||||
let teamUser1: User;
|
||||
let teamUser2: User;
|
||||
let teamUser3: User;
|
||||
let teamUser1ApiKey: string;
|
||||
let teamUser2ApiKey: string;
|
||||
|
||||
let teamRoundRobinEventTypeId: number;
|
||||
let teamRoundRobinFixedHostEventTypeId: number;
|
||||
let teamRoundRobinNonFixedEventTypeId: number;
|
||||
let teamRoundRobinWithRescheduleReasonEventTypeId: number;
|
||||
|
||||
let teamRoundRobinNonFixedEventTypeTitle: string;
|
||||
let teamRoundRobinFixedHostEventTypeTitle: string;
|
||||
let teamRoundRobinWithRescheduleReasonEventTypeTitle: string;
|
||||
|
||||
let roundRobinBooking: Booking;
|
||||
let rescheduleReasonBookingUid: string;
|
||||
let rescheduleReasonBookingInitialHostId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -76,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
@@ -96,23 +109,43 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
teamUser1 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail,
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
name: `reassign-bookings-2024-08-13-user1-${randomString()}`,
|
||||
});
|
||||
|
||||
teamUser2 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail2,
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
name: `reassign-bookings-2024-08-13-user2-${randomString()}`,
|
||||
});
|
||||
|
||||
teamUser3 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail3,
|
||||
locale: "en",
|
||||
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 = {
|
||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
availabilities: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6], // All days of the week
|
||||
startTime: new Date(Date.UTC(2024, 0, 1, 0, 0, 0)), // 00:00 UTC
|
||||
endTime: new Date(Date.UTC(2024, 0, 1, 23, 59, 0)), // 23:59 UTC
|
||||
},
|
||||
],
|
||||
};
|
||||
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser3.id, userSchedule);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser1.id}`,
|
||||
@@ -144,6 +177,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser3.id}`,
|
||||
username: teamUserEmail3,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
@@ -158,6 +206,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: teamUser3.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
@@ -230,12 +285,187 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
create: {
|
||||
email: "bob@gmail.com",
|
||||
name: "Bob",
|
||||
locale: "it",
|
||||
locale: "en",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamNonFixedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser2.id }, { id: teamUser3.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinNonFixedEventTypeId = teamNonFixedEventType.id;
|
||||
teamRoundRobinNonFixedEventTypeTitle = teamNonFixedEventType.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamNonFixedEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamNonFixedEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser1.id }, { id: teamUser2.id }, { id: teamUser3.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinFixedHostEventTypeId = team2EventType.id;
|
||||
teamRoundRobinFixedHostEventTypeTitle = team2EventType.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser3.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamEventTypeWithRescheduleReason = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
|
||||
},
|
||||
title: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||
slug: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "rescheduleReason",
|
||||
type: "textarea",
|
||||
defaultLabel: "Reason for rescheduling",
|
||||
required: true,
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
type: "default",
|
||||
label: "Default",
|
||||
},
|
||||
],
|
||||
editable: "system",
|
||||
views: [
|
||||
{
|
||||
id: "reschedule",
|
||||
label: "Reschedule View",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinWithRescheduleReasonEventTypeId = teamEventTypeWithRescheduleReason.id;
|
||||
teamRoundRobinWithRescheduleReasonEventTypeTitle = teamEventTypeWithRescheduleReason.title;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamEventTypeWithRescheduleReason.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamEventTypeWithRescheduleReason.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -248,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -287,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||
.send(body)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -307,6 +539,247 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct title when reassigning round robin booking with non-fixed host", async () => {
|
||||
const nonFixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 9, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinNonFixedEventTypeId,
|
||||
attendee: {
|
||||
name: "Charlie",
|
||||
email: "charlie@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(nonFixedHostBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toEqual(teamUser2.id);
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser2.name} and Charlie`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(teamUser3.id);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(teamUser3.id);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser3.name} and Charlie`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have correct title when reassigning round robin booking with fixed host", async () => {
|
||||
const fixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinFixedHostEventTypeId,
|
||||
attendee: {
|
||||
name: "Alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(fixedHostBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toEqual(teamUser1.id);
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(teamUser1.id);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(teamUser1.id);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve attendee name when reassigning round robin host manually with rescheduleReason required", async () => {
|
||||
const bookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2050, 0, 10, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinWithRescheduleReasonEventTypeId,
|
||||
attendee: {
|
||||
name: "David",
|
||||
email: "david@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(bookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const bookingUid = createResponse.body.data.uid;
|
||||
rescheduleReasonBookingUid = bookingUid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toBeDefined();
|
||||
const initialHostId = booking!.userId!;
|
||||
rescheduleReasonBookingInitialHostId = initialHostId;
|
||||
|
||||
const expectedInitialTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${
|
||||
booking?.userId === teamUser1.id ? teamUser1.name : teamUser2.name
|
||||
} and David`;
|
||||
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||
expect(booking?.title).not.toContain("Nameless");
|
||||
|
||||
const reassignToHostId = initialHostId === teamUser1.id ? teamUser2.id : teamUser1.id;
|
||||
const reassignToHostName = reassignToHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(reassignToHostId);
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(reassigned?.userId).toEqual(reassignToHostId);
|
||||
|
||||
const expectedReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${reassignToHostName} and David`;
|
||||
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||
expect(reassigned?.title).not.toContain("Nameless");
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve attendee name when reassigning round robin host automatically with rescheduleReason required", async () => {
|
||||
const bookingUid = rescheduleReasonBookingUid;
|
||||
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.userId).toBeDefined();
|
||||
|
||||
const currentHostId = booking!.userId!;
|
||||
const initialHostId = rescheduleReasonBookingInitialHostId;
|
||||
const initialHostName = initialHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||
|
||||
expect(currentHostId).not.toEqual(initialHostId);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(bookingUid);
|
||||
expect(data.reassignedTo.id).toEqual(initialHostId);
|
||||
|
||||
const autoReassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||
expect(autoReassigned?.userId).toEqual(initialHostId);
|
||||
|
||||
const expectedAutoReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${initialHostName} and David`;
|
||||
expect(autoReassigned?.title).toEqual(expectedAutoReassignedTitle);
|
||||
expect(autoReassigned?.title).not.toContain("Nameless");
|
||||
});
|
||||
});
|
||||
|
||||
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) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
@@ -325,8 +798,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail3);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser3.id, teamUser3.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
|
||||
+25
@@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
seatsReferences: {
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUid(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
@@ -58,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) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Injectable, Logger, HttpException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { addGuestsHandler } from "@calcom/platform-libraries/bookings";
|
||||
import type { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class BookingGuestsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
) {}
|
||||
|
||||
async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const res = await addGuestsHandler({
|
||||
ctx: { user },
|
||||
input: { bookingId: booking.id, guests: input.guests },
|
||||
emailsEnabled,
|
||||
});
|
||||
if (res.message === "Guests added") {
|
||||
return await this.bookingsService.getBooking(bookingUid, user);
|
||||
} else {
|
||||
throw new HttpException(
|
||||
"Failed to add guests to the booking",
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
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 { 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";
|
||||
@@ -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 { BillingService } from "@/modules/billing/services/billing.service";
|
||||
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 { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
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 { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
@@ -39,6 +38,8 @@ import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
|
||||
|
||||
import {
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
confirmBookingHandler,
|
||||
getCalendarLinks,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
RescheduleBookingInput,
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { EventType, User, Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -107,12 +110,11 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||
@@ -126,7 +128,7 @@ export class BookingsService_2024_08_13 {
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
const userIsEventTypeAdminOrOwner = authUser
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||
: false;
|
||||
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
|
||||
|
||||
@@ -195,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) {
|
||||
if (body.eventTypeId) {
|
||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||
@@ -614,7 +578,7 @@ export class BookingsService_2024_08_13 {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && booking?.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||
: false;
|
||||
|
||||
if (booking) {
|
||||
@@ -762,16 +726,26 @@ export class BookingsService_2024_08_13 {
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
try {
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
const isIndividualSeatRequest = this.isRescheduleSeatedBody(body);
|
||||
const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat(
|
||||
bookingUid,
|
||||
isIndividualSeatRequest,
|
||||
authUser
|
||||
);
|
||||
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
request,
|
||||
bookingUid,
|
||||
body
|
||||
body,
|
||||
isIndividualSeatReschedule
|
||||
);
|
||||
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
userId: bookingRequest.userId ?? authUser?.id,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
@@ -793,7 +767,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && databaseBooking.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||
: false;
|
||||
const isRecurring = !!databaseBooking.recurringEventId;
|
||||
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||
@@ -840,6 +814,57 @@ export class BookingsService_2024_08_13 {
|
||||
return booking;
|
||||
}
|
||||
|
||||
async shouldRescheduleIndividualSeat(
|
||||
bookingUid: string,
|
||||
isIndividualSeatReschedule: boolean,
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const hasSeatsPresent = booking.seatsReferences.length > 0;
|
||||
|
||||
if (!hasSeatsPresent) return false;
|
||||
|
||||
return await this.isIndividualSeatOrOrgAdminReschedule(
|
||||
isIndividualSeatReschedule,
|
||||
booking.userId,
|
||||
authUser?.id
|
||||
);
|
||||
}
|
||||
|
||||
async isIndividualSeatOrOrgAdminReschedule(
|
||||
isIndividualSeatReschedule: boolean,
|
||||
bookingUserId: number | null,
|
||||
authUserId?: number | null
|
||||
) {
|
||||
if (isIndividualSeatReschedule) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authUserId) {
|
||||
throw new Error(`No auth user found`);
|
||||
}
|
||||
|
||||
if (!bookingUserId) {
|
||||
throw new Error(`No user found for booking`);
|
||||
}
|
||||
|
||||
const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
|
||||
authUserId,
|
||||
bookingUserId
|
||||
);
|
||||
|
||||
return isOrgAdmin;
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return "seatUid" in body;
|
||||
}
|
||||
|
||||
async cancelBooking(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
@@ -969,19 +994,34 @@ export class BookingsService_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
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
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
const profile = this.usersService.getUserMainProfile(reassignedByUser);
|
||||
|
||||
try {
|
||||
await roundRobinReassignment({
|
||||
@@ -989,7 +1029,7 @@ export class BookingsService_2024_08_13 {
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
reassignedById: reassignedByUser.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -1013,13 +1053,27 @@ export class BookingsService_2024_08_13 {
|
||||
async reassignBookingToUser(
|
||||
bookingUid: string,
|
||||
newUserId: number,
|
||||
reassignedById: number,
|
||||
reassignedByUser: ApiAuthGuardUser,
|
||||
body: ReassignToUserBookingInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
if (!booking) {
|
||||
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);
|
||||
if (!user) {
|
||||
@@ -1040,7 +1094,7 @@ export class BookingsService_2024_08_13 {
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
reassignedById: reassignedByUser.id,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
eventTypeBookingFieldsSchema,
|
||||
EventTypeWithOwnerAndTeam,
|
||||
@@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
async createRescheduleBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: RescheduleBookingInput
|
||||
body: RescheduleBookingInput,
|
||||
isIndividualSeatReschedule: boolean
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
const bodyTransformed =
|
||||
isIndividualSeatReschedule && "seatUid" in body
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule);
|
||||
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
|
||||
bodyTransformed.eventTypeId
|
||||
@@ -554,7 +556,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputRescheduleSeatedBooking(
|
||||
@@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||
async transformInputRescheduleBooking(
|
||||
bookingUid: string,
|
||||
inputBooking: RescheduleBookingInput_2024_08_13,
|
||||
isIndividualSeatReschedule: boolean
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
@@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) {
|
||||
throw new BadRequestException(
|
||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
@@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
@@ -770,7 +775,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
defaultBookingMetadata,
|
||||
defaultBookingResponses,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export class EventTypesService_2024_04_15 {
|
||||
}
|
||||
|
||||
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 bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
|
||||
+302
@@ -32,6 +32,8 @@ import {
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
BaseConfirmationPolicy_2024_06_14,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
@@ -95,6 +97,8 @@ describe("Event types Endpoints", () => {
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let apiKeyString: string;
|
||||
let apiKeyOrgUser: string;
|
||||
let systemAdminUser: User;
|
||||
let systemAdminApiKeyString: string;
|
||||
|
||||
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
|
||||
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
|
||||
@@ -208,6 +212,17 @@ describe("Event types Endpoints", () => {
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
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({
|
||||
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
|
||||
name: `event-types-2024-06-14-org-user-${randomString()}`,
|
||||
@@ -1366,6 +1381,9 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
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.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
@@ -1387,6 +1405,99 @@ describe("Event types Endpoints", () => {
|
||||
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 () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
||||
@@ -1641,6 +1752,197 @@ describe("Event types Endpoints", () => {
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("confirmationPolicy", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not create an event type with type 'time' without noticeThreshold", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation with time",
|
||||
slug: "event-confirmation-time-missing-threshold",
|
||||
description: "This event requires confirmation based on time.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should not update an event type to type 'time' without noticeThreshold", async () => {
|
||||
// First create an event type with type 'always'
|
||||
const createBody: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation always",
|
||||
slug: "event-confirmation-always-to-time",
|
||||
description: "This event always requires confirmation.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(createBody)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
// Try to update to type 'time' without noticeThreshold
|
||||
const updateBody: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(updateBody)
|
||||
.expect(400);
|
||||
|
||||
expect(updateResponse.body.error).toBeDefined();
|
||||
expect(updateResponse.status).toBe(400);
|
||||
|
||||
// Cleanup
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
let eventTypeWithConfirmationTimeId: number;
|
||||
|
||||
it("should create an event type with type 'always'", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation always",
|
||||
slug: "event-confirmation-always",
|
||||
description: "This event always requires confirmation.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with type 'time' and noticeThreshold", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Event requiring confirmation with time",
|
||||
slug: "event-confirmation-time-with-threshold",
|
||||
description: "This event requires confirmation based on time.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
unit: NoticeThresholdUnitEnum.HOURS,
|
||||
count: 24,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
eventTypeWithConfirmationTimeId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type from type 'time' to 'always'", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
||||
expect(policy?.noticeThreshold).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type from type 'always' to 'time' with noticeThreshold", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
count: 120,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
|
||||
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
||||
expect(policy?.noticeThreshold).toEqual({
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
count: 120,
|
||||
});
|
||||
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(true);
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithConfirmationTimeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle event-types booking fields", () => {
|
||||
|
||||
+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 { 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 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 {
|
||||
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 { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.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 {
|
||||
Controller,
|
||||
@@ -73,7 +77,8 @@ export class EventTypesController_2024_06_14 {
|
||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -107,24 +112,44 @@ export class EventTypesController_2024_06_14 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
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(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): 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) {
|
||||
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 {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
||||
data: responseEventType,
|
||||
};
|
||||
}
|
||||
|
||||
private isTeamEventType(
|
||||
eventType: DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType)
|
||||
): eventType is DatabaseTeamEventType {
|
||||
return !!eventType.teamId;
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
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 { AppsRepository } from "@/modules/apps/apps.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 { 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 { RedisModule } from "@/modules/redis/redis.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 { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -26,6 +31,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesService_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
EventTypeAccessService,
|
||||
TeamsRepository,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
@@ -35,6 +42,9 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
TeamsEventTypesRepository,
|
||||
],
|
||||
controllers: [EventTypesController_2024_06_14],
|
||||
exports: [
|
||||
@@ -42,6 +52,7 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesRepository_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
EventTypeAccessService,
|
||||
],
|
||||
})
|
||||
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) {
|
||||
const eventType = await this.dbRead.prisma.eventType.findUnique({
|
||||
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 { IsIn, ValidateNested } from "class-validator";
|
||||
import { IsIn } from "class-validator";
|
||||
|
||||
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 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput_2024_06_14,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(EventTypeOutput_2024_06_14) },
|
||||
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
data!: EventTypeOutput_2024_06_14 | null;
|
||||
@Type(() => Object)
|
||||
data!: EventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14 | null;
|
||||
}
|
||||
|
||||
+32
-2
@@ -1,10 +1,14 @@
|
||||
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 { 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 { 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 { 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 { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -30,7 +34,8 @@ export class EventTypesService_2024_06_14 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
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) {
|
||||
@@ -39,7 +44,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
|
||||
|
||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||
|
||||
// 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) {
|
||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||
if (existsWithSlug) {
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("Me Endpoints", () => {
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
const userEmail = `me-controller-user-${randomString()}@api.com`;
|
||||
const name = "Me Controller User";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
|
||||
@@ -55,6 +56,7 @@ describe("Me Endpoints", () => {
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
name,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -92,6 +94,7 @@ describe("Me Endpoints", () => {
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.name).toEqual(user.name);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
|
||||
@@ -23,6 +23,10 @@ export class MeOutput {
|
||||
@DocsProperty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ type: String, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty()
|
||||
timeFormat!: number;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -24,9 +25,10 @@ import { Module } from "@nestjs/common";
|
||||
CacheService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
HashedLinkService,
|
||||
LuckyUserService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
})
|
||||
export class RegularBookingModule {}
|
||||
export class RegularBookingModule { }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { HashedLinkService as BaseHashedLinkService } from "@calcom/platform-libraries/private-links";
|
||||
|
||||
@Injectable()
|
||||
export class HashedLinkService extends BaseHashedLinkService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repos
|
||||
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 { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -16,6 +17,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
||||
prismaWriteService: PrismaWriteService,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
hashedLinkService: HashedLinkService,
|
||||
luckyUserService: LuckyUserService,
|
||||
userRepository: PrismaUserRepository
|
||||
) {
|
||||
@@ -24,6 +26,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
checkBookingAndDurationLimitsService,
|
||||
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
||||
bookingRepository,
|
||||
hashedLinkService,
|
||||
luckyUserService,
|
||||
userRepository,
|
||||
});
|
||||
|
||||
@@ -286,10 +286,9 @@ export class EventTypesAtomService {
|
||||
.filter(({ ...app }) => app.slug === slug)
|
||||
.map(
|
||||
async ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
credentials: _,
|
||||
credential,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
key: _2 /* don't leak to frontend */,
|
||||
...app
|
||||
}: EnabledAppType) => {
|
||||
@@ -368,7 +367,7 @@ export class EventTypesAtomService {
|
||||
endTime: endTime.toString(),
|
||||
};
|
||||
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
|
||||
if (eventType.users.length === 0 && !!!eventType.team)
|
||||
if (eventType.users.length === 0 && !eventType.team)
|
||||
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
|
||||
const [user] = eventType?.users.length
|
||||
? eventType.users
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RolesModule } from "@/modules/roles/roles.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
RolesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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 { Request } from "express";
|
||||
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
private readonly logger = new Logger("RolesGuard Logger");
|
||||
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
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 orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
@@ -305,11 +305,24 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
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, {
|
||||
items: [
|
||||
{
|
||||
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
||||
},
|
||||
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -31,6 +31,7 @@ import {
|
||||
Delete,
|
||||
Query,
|
||||
NotFoundException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
@@ -111,7 +112,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async getUserById(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
const user = await this.validateManagedUserOwnership(clientId, userId);
|
||||
|
||||
@@ -127,7 +128,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async updateUser(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() body: UpdateManagedUserInput,
|
||||
@GetOrgId() organizationId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
@@ -153,7 +154,7 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async deleteUser(
|
||||
@Param("clientId") clientId: string,
|
||||
@Param("userId") userId: number
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetManagedUserOutput> {
|
||||
const user = await this.validateManagedUserOwnership(clientId, userId);
|
||||
await this.userRepository.delete(userId);
|
||||
@@ -174,7 +175,7 @@ export class OAuthClientUsersController {
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@Param("userId") userId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("clientId") oAuthClientId: string
|
||||
): Promise<KeysResponseDto> {
|
||||
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
HostPriority,
|
||||
EmailSettings_2024_06_14,
|
||||
} 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<
|
||||
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
||||
@@ -116,10 +120,12 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
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 isManagedEventType = rest.schedulingType === "MANAGED";
|
||||
|
||||
const defaultLocations: CreateTeamEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
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 =
|
||||
rest.schedulingType === "MANAGED"
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
let metadata = isManagedEventType
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
@@ -140,12 +147,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !(rest.schedulingType === "MANAGED")
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, inputEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputCreateTeamEventTypeHosts(teamId, inputEventType),
|
||||
assignAllTeamMembers,
|
||||
locations: this.transformInputTeamLocations(locations || defaultLocations),
|
||||
metadata,
|
||||
@@ -155,6 +157,24 @@ export class InputOrganizationsEventTypesService {
|
||||
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(
|
||||
emailSettings: EmailSettings_2024_06_14,
|
||||
metadata: NonNullable<EventTypeMetadata>
|
||||
@@ -190,7 +210,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
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 dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||
@@ -213,11 +233,11 @@ export class InputOrganizationsEventTypesService {
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
hosts: !children
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
||||
: undefined,
|
||||
hosts: await this.transformInputUpdateTeamEventTypeHosts(
|
||||
teamId,
|
||||
dbEventType.schedulingType,
|
||||
inputEventType
|
||||
),
|
||||
assignAllTeamMembers,
|
||||
children,
|
||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||
@@ -227,6 +247,33 @@ export class InputOrganizationsEventTypesService {
|
||||
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(
|
||||
eventTypeId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
@@ -273,7 +320,7 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeCreate(
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">,
|
||||
teamId: number
|
||||
) {
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||
@@ -289,7 +336,7 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
async getOwnersIdsForManagedEventTypeCreate(
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
inputEventType: Pick<CreateTeamEventTypeInput_2024_06_14, "assignAllTeamMembers" | "hosts">
|
||||
) {
|
||||
if (inputEventType.assignAllTeamMembers) {
|
||||
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 { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRepository {
|
||||
export class OrganizationsRepository extends OrganizationRepository {
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async findById(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
) {
|
||||
super({ prismaClient: dbWrite.prisma });
|
||||
}
|
||||
|
||||
async findByIds(organizationIds: number[]) {
|
||||
|
||||
@@ -6,7 +6,7 @@ export class OrganizationsService {
|
||||
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async isPlatform(organizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(organizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: organizationId });
|
||||
return organization?.isPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.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 { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
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 { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
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 { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
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 { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
||||
@@ -96,6 +99,8 @@ import { Module } from "@nestjs/common";
|
||||
TeamsModule,
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsRolesModule,
|
||||
OrganizationsTeamsRolesModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
MembershipsModule,
|
||||
@@ -104,6 +109,7 @@ import { Module } from "@nestjs/common";
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationMembershipService,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsService,
|
||||
OrganizationsTeamsService,
|
||||
@@ -196,4 +202,4 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesPrivateLinksController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
export class OrganizationsModule { }
|
||||
|
||||
+2
-2
@@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
|
||||
}
|
||||
|
||||
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managerOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
|
||||
return !!organization?.isPlatform;
|
||||
}
|
||||
|
||||
async getManagedOrganization(managedOrganizationId: number) {
|
||||
const organization = await this.organizationsRepository.findById(managedOrganizationId);
|
||||
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
|
||||
if (!organization) {
|
||||
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 || []);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user