Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a06797a9 | ||
|
|
99eb743772 | ||
|
|
73adfb0cec | ||
|
|
7127c60116 | ||
|
|
a6d37fd167 | ||
|
|
16ea15076d | ||
|
|
041871f1b6 | ||
|
|
8c8ae127fe | ||
|
|
357c82129c | ||
|
|
324ac691eb | ||
|
|
da99001a7a | ||
|
|
bd10d07083 | ||
|
|
0e9b419a07 | ||
|
|
d5bd08d279 | ||
|
|
4cc83530f9 | ||
|
|
4fcf4081d0 | ||
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 | ||
|
|
d15490d5aa | ||
|
|
ba6e547f6c | ||
|
|
2493315f92 | ||
|
|
ec117d4034 | ||
|
|
646058b8a1 | ||
|
|
06c359ac9b | ||
|
|
0d96d9c44c | ||
|
|
e5f8d4a1ac | ||
|
|
19c72323b8 | ||
|
|
f19b2804b6 | ||
|
|
f387fdb166 | ||
|
|
ec8f9d5f7a | ||
|
|
6cb3bdc567 | ||
|
|
d574840c3f | ||
|
|
6a85d0e10b | ||
|
|
816df3704e | ||
|
|
badd9a5698 | ||
|
|
4e02fe4251 | ||
|
|
01b9780938 | ||
|
|
6be144f95f | ||
|
|
37dd6d888b | ||
|
|
f5ffee0a20 | ||
|
|
6cd51e0a8d | ||
|
|
80fad2aab9 | ||
|
|
1ccfc65766 | ||
|
|
86918b03cf | ||
|
|
a5b684af1f | ||
|
|
8889e8566c | ||
|
|
fab364f084 | ||
|
|
5bc9d71a88 | ||
|
|
2b5ea649d4 | ||
|
|
0802ebb17b | ||
|
|
29b8d68f45 | ||
|
|
6994cac5e0 | ||
|
|
df9b72583d | ||
|
|
553eab59e6 | ||
|
|
8d675fd8b6 | ||
|
|
a5639a8290 | ||
|
|
44d9c4bcc5 | ||
|
|
1b586560f8 | ||
|
|
e082cc7d3d | ||
|
|
d411e70ea3 | ||
|
|
8dffa4d371 | ||
|
|
b7bf6e80e6 | ||
|
|
2a93e661f9 | ||
|
|
bfb51b82b2 | ||
|
|
f50be6c08b | ||
|
|
df75df7008 | ||
|
|
deb55c0536 | ||
|
|
7fc11ff341 | ||
|
|
1a70f83d34 | ||
|
|
39077a08f1 | ||
|
|
9a671f9063 | ||
|
|
3bc9fa11d0 | ||
|
|
7e699ab810 | ||
|
|
1bd17ba365 | ||
|
|
4d5246cf4d | ||
|
|
5d988453fa | ||
|
|
85b9782264 | ||
|
|
c2b091aa67 | ||
|
|
7093e1e4c6 | ||
|
|
ead42c1a9c | ||
|
|
e669996442 | ||
|
|
0e6f365dc2 | ||
|
|
08b501625d | ||
|
|
b89ff43921 | ||
|
|
fadfba8830 | ||
|
|
43be9bd6a8 | ||
|
|
caf184151d | ||
|
|
22e3338709 | ||
|
|
048e56806c | ||
|
|
ade64c0d09 | ||
|
|
5beb2943b9 | ||
|
|
b3d3cb1dee | ||
|
|
79d695236f | ||
|
|
2dbbdc21f2 | ||
|
|
e4254ef7cc | ||
|
|
7f9709bdaa | ||
|
|
77054d92df | ||
|
|
1277f5e9f7 | ||
|
|
109c303b60 | ||
|
|
a52a0e90e7 | ||
|
+25 |
558140fdd6 | ||
|
|
fa09fd66ff | ||
|
|
b1cbeebe48 | ||
|
|
93bad282d7 | ||
|
|
47d941676e | ||
|
|
54c4b0b65e | ||
|
|
0f8356f417 | ||
|
|
cecfcc7c6f | ||
|
|
0b3f9d06a9 | ||
|
|
71e7300830 | ||
|
|
bccffb328b | ||
|
|
80fa172f79 | ||
|
|
50173658f8 | ||
|
|
dddbd7dad4 | ||
|
|
ae2d85d81b | ||
|
|
80c407a4b7 | ||
|
|
6e9ec113fe | ||
|
|
3ee6a7ecde | ||
|
|
8ad62d183c | ||
|
|
f047799299 | ||
|
|
3a9e353443 | ||
|
|
b73a3aeb0c | ||
|
|
b6ff64ed74 | ||
|
|
1b281fe269 | ||
|
|
024913592b | ||
|
|
78a34038d2 | ||
|
|
5f770a9789 | ||
|
|
24a43877ce | ||
|
|
4a96be4175 | ||
|
|
594bb606b8 | ||
|
|
1c088f65c5 | ||
|
|
7d7a429780 | ||
|
|
d3228441aa | ||
|
|
21d6bf1fca | ||
|
|
a4134aa5a3 | ||
|
|
61397e5184 | ||
|
|
6143cd60e5 | ||
|
|
ac821f6b13 | ||
|
|
2bce38fd2a | ||
|
|
390ee79134 | ||
|
|
cfc9f0bfc3 | ||
|
|
58d5f2a17c | ||
|
|
37f271bd74 | ||
|
|
d770cfc65e | ||
|
|
ab8556790e | ||
|
|
3d89b37cc6 | ||
|
|
78b213321c | ||
|
|
276ba1179f | ||
|
|
0109d166a1 | ||
|
|
779c014256 | ||
|
|
073faf9e10 | ||
|
|
d0e39cf2dd | ||
|
|
f9488a1866 | ||
|
|
d4a3894a9e | ||
|
|
91288b41cd | ||
|
|
1aa2f68e73 | ||
|
|
ad707b0ee4 | ||
|
|
49a2f4d4f7 | ||
|
|
f576979345 | ||
|
|
b45399ab78 | ||
|
|
306dc5e4fb | ||
|
|
605304b93b | ||
|
|
d6a17b2b1e | ||
|
|
4bee875a8c | ||
|
|
fe4ae3ae62 | ||
|
|
a245578ff2 | ||
|
|
f5ec696e15 | ||
|
|
ec9384858b | ||
|
|
47613f796e | ||
|
|
446c780d85 | ||
|
|
615968c640 | ||
|
|
fc89436b6a | ||
|
|
3c1015cb11 | ||
|
|
46c513330c | ||
|
|
ef1598faeb | ||
|
|
42a6903057 | ||
|
|
0f0c2b8b92 | ||
|
|
c400c36306 | ||
|
|
6410bd2eaf | ||
|
|
2ddc5317b8 | ||
|
|
6fffe3377d | ||
|
|
eedef35d02 | ||
|
|
08d232a816 | ||
|
|
d9b3f8657f | ||
|
|
99dc5837f5 | ||
|
|
5a35540522 | ||
|
|
c01fc08f67 | ||
|
|
7397722eb3 | ||
|
|
d51e03b14b | ||
|
|
c4af180a5b | ||
|
|
ee5f1dc879 | ||
|
|
15b43176ff | ||
|
|
03240709dc | ||
|
|
7ee451ca02 | ||
|
|
55b066c475 | ||
|
|
c52bbc4be3 | ||
|
|
31b1f31d82 | ||
|
|
ab1c1bb015 | ||
|
|
45219f4736 | ||
|
|
3296255f80 | ||
|
|
81b4651d6e | ||
|
|
e3bd90c4f2 | ||
|
|
17f1932467 | ||
|
|
1b04fe82a7 | ||
|
|
ffb36509ff | ||
|
|
dc361f7879 | ||
|
|
0b59a5d184 | ||
|
|
54c17eb827 | ||
|
|
bd1142ea7a | ||
|
|
26051833cf | ||
|
|
1d8bc6ca49 | ||
|
|
fbe8cf8223 | ||
|
|
ef8b3e83ac | ||
|
|
9d228a1d96 | ||
|
|
57df25505d | ||
|
|
31cbea0e23 | ||
|
|
edd80c056c | ||
|
|
6ebf0db389 |
No files matched your search
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": []
|
"ignore": [],
|
||||||
|
"privatePackages": {
|
||||||
|
"version": false,
|
||||||
|
"tag": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+17
-7
@@ -159,13 +159,17 @@ SENDGRID_EMAIL=
|
|||||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
SENTRY_DEBUG=
|
SENTRY_DEBUG=
|
||||||
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
|
|
||||||
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
|
|
||||||
SENTRY_MAX_SPANS=
|
SENTRY_MAX_SPANS=
|
||||||
|
SENTRY_SAMPLE_RATE=
|
||||||
SENTRY_TRACES_SAMPLE_RATE=
|
SENTRY_TRACES_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||||
|
|
||||||
# Formbricks Experience Management Integration
|
# Formbricks Experience Management Integration
|
||||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||||
@@ -184,6 +188,10 @@ TWILIO_TOKEN=
|
|||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
TWILIO_PHONE_NUMBER=
|
TWILIO_PHONE_NUMBER=
|
||||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||||
|
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
|
||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
@@ -198,6 +206,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||||
STRIPE_TEAM_PRODUCT_ID=
|
STRIPE_TEAM_PRODUCT_ID=
|
||||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
@@ -363,9 +372,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
|||||||
|
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# disable setry server source maps
|
|
||||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
|
||||||
|
|
||||||
# api v2
|
# api v2
|
||||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||||
|
|
||||||
@@ -425,7 +431,7 @@ DIRECTORY_IDS_TO_LOG=
|
|||||||
# Read more about it in the README.md
|
# Read more about it in the README.md
|
||||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||||
|
|
||||||
AKISMET_API_KEY=
|
IFFY_API_KEY=
|
||||||
|
|
||||||
## Env variables related to avoiding booking failures
|
## Env variables related to avoiding booking failures
|
||||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||||
@@ -443,4 +449,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
|||||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||||
|
|
||||||
DATABASE_CHUNK_SIZE=
|
DATABASE_CHUNK_SIZE=
|
||||||
|
|
||||||
|
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||||
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
|
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||||
+5
-5
@@ -1,9 +1,7 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
/apps/ui-playground/**/* @calcom/Consumer
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/components/**/* @calcom/Consumer
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/modules/**/* @calcom/Consumer
|
|
||||||
/apps/web/**/layout.tsx @calcom/Foundation
|
|
||||||
/apps/web/lib/**/* @calcom/Foundation
|
/apps/web/lib/**/* @calcom/Foundation
|
||||||
/apps/web/middleware.ts @calcom/Foundation
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
@@ -26,5 +24,7 @@
|
|||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
/packages/ui/**/* @calcom/Consumer
|
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
permissions: {} # remove permissions and set explicitly under "permissions:" 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: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup node, yarn and install dependencies
|
||||||
|
uses: ./.github/actions/yarn-install
|
||||||
|
- name: Create Release Pull Request or Publish to npm
|
||||||
|
id: changesets
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
version: yarn changesets-version
|
||||||
|
publish: yarn changesets-release
|
||||||
|
commit: "chore: version packages" # commit message
|
||||||
|
title: "chore: version packages" # PR title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
cleanup-report:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout GitHub Pages Branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: calcom/test-results
|
||||||
|
ref: gh-pages
|
||||||
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set Git User
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Cleanup HTML reports on PR close/merge
|
||||||
|
env:
|
||||||
|
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
|
||||||
|
run: |
|
||||||
|
if [ -d "$BRANCH_REPORTS_DIR" ]; then
|
||||||
|
rm -rf "$BRANCH_REPORTS_DIR"
|
||||||
|
git add .
|
||||||
|
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
|
||||||
|
git push
|
||||||
|
else
|
||||||
|
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Cron - checkSmsPrices
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs “every minute” (see https://crontab.guru)
|
||||||
|
- cron: "* * * * *"
|
||||||
|
jobs:
|
||||||
|
cron-checkSmsPrices:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -219,6 +219,17 @@ jobs:
|
|||||||
uses: ./.github/workflows/publish-report.yml
|
uses: ./.github/workflows/publish-report.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
cleanup-report:
|
||||||
|
name: Cleanup HTML report
|
||||||
|
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
needs: [changes, check-label, publish-report]
|
||||||
|
uses: ./.github/workflows/cleanup-report.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
# Cal.com Permission Documentation
|
||||||
|
|
||||||
|
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
|
||||||
|
|
||||||
|
## Permissions by Resource
|
||||||
|
|
||||||
|
### Team Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
|
||||||
|
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
|
||||||
|
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
|
||||||
|
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
|
||||||
|
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
|
||||||
|
|
||||||
|
### Event Type Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
|
||||||
|
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
|
||||||
|
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
|
||||||
|
|
||||||
|
### Booking Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
|
||||||
|
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
|
||||||
|
### Organization Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
|
||||||
|
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
|
||||||
|
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
|
||||||
|
|
||||||
|
### API Key Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
|
||||||
|
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
|
||||||
|
|
||||||
|
## Helper Functions and Utilities
|
||||||
|
|
||||||
|
The following helper functions are commonly used for permission checks:
|
||||||
|
|
||||||
|
| Function | Description | File Path |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
|
||||||
|
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
|
||||||
|
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
|
||||||
|
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
|
||||||
|
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
|
||||||
|
|
||||||
|
## Permission Level Enums
|
||||||
|
|
||||||
|
The codebase uses several enums to define permission levels:
|
||||||
|
|
||||||
|
### MembershipRole Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum MembershipRole {
|
||||||
|
OWNER = "OWNER",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
MEMBER = "MEMBER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entity Permission Level Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ENTITY_PERMISSION_LEVEL {
|
||||||
|
NONE,
|
||||||
|
USER_ONLY_WRITE,
|
||||||
|
TEAM_READ_ONLY,
|
||||||
|
TEAM_WRITE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Permission Patterns
|
||||||
|
|
||||||
|
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
|
||||||
|
```typescript
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
|
||||||
|
```typescript
|
||||||
|
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
|
||||||
|
```typescript
|
||||||
|
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Entity Permission Check**: Entity operations use permission level checks.
|
||||||
|
```typescript
|
||||||
|
const permissionLevel = await getEntityPermissionLevel(entity, userId);
|
||||||
|
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
|
||||||
|
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
|
||||||
|
```typescript
|
||||||
|
const membershipIdsWhereUserIsAdminOwner = (
|
||||||
|
await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
role: {
|
||||||
|
in: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((membership) => membership.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration to PBAC
|
||||||
|
|
||||||
|
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
|
||||||
|
|
||||||
|
// Instead of:
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission String Alternatives for Helper Functions
|
||||||
|
|
||||||
|
The following table provides permission string alternatives for common helper functions:
|
||||||
|
|
||||||
|
| Helper Function | Permission String Alternative | Description |
|
||||||
|
|-----------------|-------------------------------|-------------|
|
||||||
|
| isTeamAdmin | `team.*` | Grants all team permissions |
|
||||||
|
| isTeamAdmin | `team.update` | Update team settings |
|
||||||
|
| isTeamAdmin | `team.invite` | Invite team members |
|
||||||
|
| isTeamAdmin | `team.remove` | Remove team members |
|
||||||
|
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
|
||||||
|
| isTeamOwner | `team.delete` | Delete team |
|
||||||
|
| isTeamMember | `team.read` | Read-only access to team |
|
||||||
|
|
||||||
|
## Entity Permission Functions and Resources
|
||||||
|
|
||||||
|
The following functions are used to check permissions for entities with userId and teamId properties:
|
||||||
|
|
||||||
|
| Function | Description | Permission String Alternative |
|
||||||
|
|----------|-------------|-------------------------------|
|
||||||
|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
|
||||||
|
|
||||||
|
### Resources Used with Entity Permission Functions
|
||||||
|
|
||||||
|
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|
||||||
|
|----------|-------------------|---------------------|------------------|
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
|
||||||
|
|
||||||
|
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Instead of:
|
||||||
|
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows for more granular permission control and the creation of custom roles with specific permissions.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Possible issue:
|
||||||
|
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
|
||||||
|
|
||||||
|
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
|
||||||
|
- Delete Credentials when DelegationCredential is disabled
|
||||||
|
|
||||||
|
|
||||||
|
TO Test
|
||||||
|
- New members beyond the batch size are processed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -646,6 +646,14 @@ following
|
|||||||
15. Create a verify service
|
15. Create a verify service
|
||||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||||
|
|
||||||
|
## Changesets
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Constructs the WHERE clause for Prisma booking findMany operation.
|
||||||
|
*
|
||||||
|
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
||||||
|
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
||||||
|
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
||||||
|
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
||||||
|
*
|
||||||
|
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||||
|
*/
|
||||||
|
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
|
||||||
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||||
|
|
||||||
|
let whereClause = {};
|
||||||
|
|
||||||
|
if (filterByAttendeeEmails) {
|
||||||
|
whereClause = {
|
||||||
|
AND: [
|
||||||
|
userFilter,
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: attendeeEmails },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
whereClause = {
|
||||||
|
...userFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereClause;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@sentry/nextjs": "^8.8.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.9",
|
"next": "^13.5.9",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma, Booking } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
import {
|
import {
|
||||||
getAccessibleUsers,
|
getAccessibleUsers,
|
||||||
retrieveOrgScopedAccessibleUsers,
|
retrieveOrgScopedAccessibleUsers,
|
||||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
|
||||||
*
|
|
||||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
|
||||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
|
||||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
|
||||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
|
||||||
*
|
|
||||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
userId: number | null,
|
|
||||||
attendeeEmails: string[],
|
|
||||||
userIds: number[] = [],
|
|
||||||
userEmails: string[] = []
|
|
||||||
) {
|
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
|
||||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
|
||||||
|
|
||||||
let whereClause = {};
|
|
||||||
|
|
||||||
if (filterByAttendeeEmails) {
|
|
||||||
whereClause = {
|
|
||||||
AND: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: attendeeEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
whereClause = {
|
|
||||||
OR: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: userEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handler(req: NextApiRequest) {
|
export async function handler(req: NextApiRequest) {
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||||
: [];
|
: [];
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
let userEmailsToFilterBy: string[] = [];
|
||||||
|
|
||||||
/** Only admins can query other users */
|
/** Only admins can query other users */
|
||||||
if (isSystemWideAdmin) {
|
if (isSystemWideAdmin) {
|
||||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
}
|
}
|
||||||
} else if (isOrganizationOwnerOrAdmin) {
|
} else if (isOrganizationOwnerOrAdmin) {
|
||||||
let requestedUserIds = [userId];
|
let requestedUserIds = [userId];
|
||||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
} else {
|
} else {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
let data: Booking[] = [];
|
||||||
|
|
||||||
|
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
|
||||||
|
const queryOne = prisma.booking.findMany(args);
|
||||||
|
|
||||||
|
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: userEmailsToFilterBy },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: dateFrom };
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauseForQueryTwo.endTime = { lte: dateTo };
|
||||||
|
}
|
||||||
|
if (status === "upcoming") {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
|
||||||
|
...args,
|
||||||
|
where: whereClauseForQueryTwo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
|
||||||
|
|
||||||
|
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
|
||||||
|
|
||||||
|
const bookingMap = new Map();
|
||||||
|
[...resultOne, ...resultTwo].forEach((booking) => {
|
||||||
|
bookingMap.set(booking.id, booking);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupedResults = Array.from(bookingMap.values());
|
||||||
|
|
||||||
|
if (args.orderBy) {
|
||||||
|
let sortField: keyof Booking;
|
||||||
|
let sortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
|
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
|
||||||
|
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
|
||||||
|
sortField = orderByKey as keyof Booking;
|
||||||
|
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
|
||||||
|
} else {
|
||||||
|
sortField = "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = sortDirection === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
dedupedResults.sort((a, b) => {
|
||||||
|
const aValue = a[sortField];
|
||||||
|
const bValue = b[sortField];
|
||||||
|
|
||||||
|
if (aValue < bValue) return -1 * sortOrder;
|
||||||
|
if (aValue > bValue) return 1 * sortOrder;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.take !== undefined && args.skip !== undefined) {
|
||||||
|
data = dedupedResults.slice(args.skip, args.skip + args.take);
|
||||||
|
} else {
|
||||||
|
data = dedupedResults;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await prisma.booking.findMany(args);
|
||||||
|
}
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
|||||||
currentCredentialId: number | null;
|
currentCredentialId: number | null;
|
||||||
}) {
|
}) {
|
||||||
if (parsedBody.integration && parsedBody.externalId) {
|
if (parsedBody.integration && parsedBody.externalId) {
|
||||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||||
|
userCredentials.map((cred) => ({
|
||||||
|
...cred,
|
||||||
|
delegationCredentialId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const { connectedCalendars } = await getConnectedCalendars(
|
const { connectedCalendars } = await getConnectedCalendars(
|
||||||
calendarCredentials,
|
calendarCredentials,
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
|
|||||||
*/
|
*/
|
||||||
async function postHandler(req: NextApiRequest) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const data = membershipCreateBodySchema.parse(req.body);
|
const data = membershipCreateBodySchema.parse(req.body);
|
||||||
const args: Prisma.MembershipCreateArgs = { data };
|
const args: Prisma.MembershipCreateArgs = {
|
||||||
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
|
|||||||
console.log("bookings=>", responseData.bookings);
|
console.log("bookings=>", responseData.bookings);
|
||||||
responseData.bookings.forEach((booking) => {
|
responseData.bookings.forEach((booking) => {
|
||||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Date filtering", () => {
|
||||||
|
it("filters bookings by dateFrom", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "2023-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
|
||||||
|
new Date("2023-01-01T00:00:00Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters bookings by dateTo", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "2024-12-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
|
||||||
|
new Date("2024-12-31T23:59:59Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sorting and ordering", () => {
|
||||||
|
it("sorts bookings by createdAt in descending order", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
|
||||||
|
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple attendee email filtering", () => {
|
||||||
|
it("filters bookings by multiple attendee emails", async () => {
|
||||||
|
const attendeeEmails = ["test1@example.com", "test2@example.com"];
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: attendeeEmails,
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
|
||||||
|
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error cases", () => {
|
||||||
|
it("throws error for invalid status parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Result merging", () => {
|
||||||
|
it("does not return duplicate bookings when merging results from multiple queries", async () => {
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
|
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: {
|
||||||
|
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 100,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
|
||||||
|
const bookingIds = responseData.bookings.map((booking) => booking.id);
|
||||||
|
|
||||||
|
const uniqueBookingIds = new Set(bookingIds);
|
||||||
|
|
||||||
|
expect(uniqueBookingIds.size).toBe(bookingIds.length);
|
||||||
|
|
||||||
|
if (uniqueBookingIds.size !== bookingIds.length) {
|
||||||
|
const counts = bookingIds.reduce((acc, id) => {
|
||||||
|
acc[id] = (acc[id] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
const duplicates = Object.entries(counts)
|
||||||
|
.filter(([_, count]) => count > 1)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
|
|
||||||
|
describe("buildWhereClause", () => {
|
||||||
|
it("should return empty object when no filters are provided", () => {
|
||||||
|
const result = buildWhereClause(null, [], []);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userId when only userId is provided", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const result = buildWhereClause(userId, [], []);
|
||||||
|
expect(result).toEqual({ userId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userIds when userIds array is provided", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const result = buildWhereClause(null, [], userIds);
|
||||||
|
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by attendee emails when provided", () => {
|
||||||
|
const attendeeEmails = ["test@example.com", "user@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com", "user@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userId and attendee email filters", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(userId, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: 123 },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userIds and attendee email filters", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, userIds);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: { in: [1, 2, 3] } },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
"reporters": ["default", "jest-summarizing-reporter"],
|
||||||
"workerIdleMemoryLimit": "512MB",
|
"workerIdleMemoryLimit": "512MB",
|
||||||
"maxWorkers": 8
|
"maxWorkers": 8
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Platform API for Cal.com",
|
"description": "Platform API for Cal.com",
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"private": true,
|
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn dev:build && nest build",
|
"build": "yarn dev:build && nest build",
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.176",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
|||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly oAuthFlowService: OAuthFlowService,
|
private readonly oAuthFlowService: OAuthFlowService,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferencesRepository_2024_08_13 {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
|
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
||||||
|
|
||||||
|
if (filter?.type) {
|
||||||
|
whereClause.type = filter.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dbRead.prisma.bookingReference.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
uid: true,
|
||||||
|
id: true,
|
||||||
|
externalCalendarId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
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 { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
|
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 { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
@@ -17,6 +20,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
|||||||
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingsService_2024_08_13,
|
BookingsService_2024_08_13,
|
||||||
InputBookingsService_2024_08_13,
|
InputBookingsService_2024_08_13,
|
||||||
OutputBookingsService_2024_08_13,
|
OutputBookingsService_2024_08_13,
|
||||||
|
OutputBookingReferencesService_2024_08_13,
|
||||||
BookingsRepository_2024_08_13,
|
BookingsRepository_2024_08_13,
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
BookingSeatRepository,
|
BookingSeatRepository,
|
||||||
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
|
|||||||
OrganizationsTeamsRepository,
|
OrganizationsTeamsRepository,
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
ErrorsBookingsService_2024_08_13,
|
ErrorsBookingsService_2024_08_13,
|
||||||
|
BookingReferencesService_2024_08_13,
|
||||||
|
BookingReferencesRepository_2024_08_13,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_08_13],
|
controllers: [BookingsController_2024_08_13],
|
||||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||||
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
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 { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
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 { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsService: BookingsService_2024_08_13,
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: UsersService,
|
||||||
|
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -176,7 +182,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
): Promise<GetBookingsOutput_2024_08_13> {
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
const profile = this.usersService.getUserMainProfile(user);
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
orgId: profile?.organizationId,
|
orgId: profile?.organizationId,
|
||||||
@@ -185,6 +191,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: bookings,
|
data: bookings,
|
||||||
|
pagination,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,4 +393,30 @@ export class BookingsController_2024_08_13 {
|
|||||||
data: calendarLinks,
|
data: calendarLinks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid/references")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get 'Booking References' for a booking",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getBookingReferences(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||||
|
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||||
|
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
|
||||||
|
bookingUid,
|
||||||
|
userId,
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookingReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
let seatedEvent: EventType;
|
let seatedEvent: EventType;
|
||||||
let eventTypeWithBookingFields: EventType;
|
let eventTypeWithBookingFields: EventType;
|
||||||
|
let eventTypeWithBookingFielsCustom: EventType;
|
||||||
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "test booking field types",
|
||||||
|
slug: "test-booking-field-types",
|
||||||
|
length: 30,
|
||||||
|
bookingFields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "name",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
variant: "fullName",
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "your_name",
|
||||||
|
variantsConfig: {
|
||||||
|
variants: {
|
||||||
|
fullName: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "fullName",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
firstAndLastName: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "firstName",
|
||||||
|
type: "text",
|
||||||
|
label: "",
|
||||||
|
required: true,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastName",
|
||||||
|
type: "text",
|
||||||
|
label: "",
|
||||||
|
required: false,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email",
|
||||||
|
type: "email",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "email_address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "location",
|
||||||
|
type: "radioInput",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "location",
|
||||||
|
getOptionsAt: "locations",
|
||||||
|
optionsInputs: {
|
||||||
|
phone: { type: "phone", required: true, placeholder: "" },
|
||||||
|
somewhereElse: { type: "text", required: true, placeholder: "" },
|
||||||
|
attendeeInPerson: { type: "address", required: true, placeholder: "" },
|
||||||
|
},
|
||||||
|
hideWhenJustOneOption: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-phone",
|
||||||
|
type: "phone",
|
||||||
|
label: "test-phone",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-address",
|
||||||
|
type: "address",
|
||||||
|
label: "test-address",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-text",
|
||||||
|
type: "text",
|
||||||
|
label: "test-text",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-number",
|
||||||
|
type: "number",
|
||||||
|
label: "test-number",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-textarea",
|
||||||
|
type: "textarea",
|
||||||
|
label: "test-textarea",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-boolean",
|
||||||
|
type: "boolean",
|
||||||
|
label: "test-boolean",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
labelAsSafeHtml: "<p>test-boolean</p>\n",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-url",
|
||||||
|
type: "url",
|
||||||
|
label: "test-url",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
placeholder: "",
|
||||||
|
labelAsSafeHtml: "<p>test-url</p>\n",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-multiemail",
|
||||||
|
type: "multiemail",
|
||||||
|
label: "test-multiemail",
|
||||||
|
hidden: false,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-multiselect",
|
||||||
|
type: "multiselect",
|
||||||
|
label: "test-multiselect",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "apple",
|
||||||
|
value: "apple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "orange",
|
||||||
|
value: "orange",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-checkbox",
|
||||||
|
type: "checkbox",
|
||||||
|
label: "test-checkbox",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "blue",
|
||||||
|
value: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "red",
|
||||||
|
value: "red",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-radio",
|
||||||
|
type: "radio",
|
||||||
|
label: "test-radio",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "pineapple",
|
||||||
|
value: "pineapple",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-select",
|
||||||
|
type: "select",
|
||||||
|
label: "test-select",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "water",
|
||||||
|
value: "water",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "juice",
|
||||||
|
value: "juice",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: true,
|
||||||
|
placeholder: "",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
type: "text",
|
||||||
|
hidden: true,
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "what_is_this_meeting_about",
|
||||||
|
defaultPlaceholder: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notes",
|
||||||
|
type: "textarea",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "additional_notes",
|
||||||
|
defaultPlaceholder: "share_additional_notes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guests",
|
||||||
|
type: "multiemail",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "additional_guests",
|
||||||
|
defaultPlaceholder: "email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rescheduleReason",
|
||||||
|
type: "textarea",
|
||||||
|
views: [{ id: "reschedule", label: "Reschedule View" }],
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "reason_for_reschedule",
|
||||||
|
defaultPlaceholder: "reschedule_placeholder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
||||||
user: {
|
user: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Booking Field Type Validation", () => {
|
||||||
|
const basePayload = {
|
||||||
|
start: "2025-06-19T11:00:00.000Z",
|
||||||
|
attendee: {
|
||||||
|
name: "Charlie TypeTest",
|
||||||
|
email: "charlie.typetest@example.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-phone": 12345,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-address": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-text": 123,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-number": "123",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-textarea": { text: "invalid" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-boolean": "true",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-url": 12345,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-multiemail": "not-an-array@example.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-multiselect": "not-an-array",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-checkbox": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-radio": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-select": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-select field with invalid option", async () => {
|
||||||
|
const fieldName = "test-select";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: "INVALID_DRINK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-radio field with invalid option", async () => {
|
||||||
|
const fieldName = "test-radio";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
|
||||||
|
const fieldName = "test-multiselect";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: ["apple", "INVALID_FRUIT"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
|
||||||
|
const fieldName = "test-checkbox";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: ["blue", "INVALID_COLOR"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
+4
-4
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { EventType, User } from "@prisma/client";
|
import { EventType, User, Workflow } from "@prisma/client";
|
||||||
import { advanceTo, clear } from "jest-date-mock";
|
import { advanceTo, clear } from "jest-date-mock";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
|
|||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
|
||||||
|
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
VERSION_2024_08_13,
|
VERSION_2024_08_13,
|
||||||
X_CAL_CLIENT_ID,
|
X_CAL_CLIENT_ID,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
|
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||||
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let schedulesService: SchedulesService_2024_04_15;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
|
||||||
|
let workflowRepositoryFixture: WorkflowRepositoryFixture;
|
||||||
let oAuthClient: PlatformOAuthClient;
|
let oAuthClient: PlatformOAuthClient;
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
let eventType: EventType;
|
let eventType: EventType;
|
||||||
|
let eventTypeWithAttendeeSmsReminder: EventType;
|
||||||
|
let workflow: Workflow;
|
||||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||||
let recurringEventTypeId: number;
|
let recurringEventTypeId: number;
|
||||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||||
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
|
||||||
|
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({
|
organization = await teamRepositoryFixture.create({
|
||||||
name: `user-bookings-organization-${randomString()}`,
|
name: `user-bookings-organization-${randomString()}`,
|
||||||
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
rating: 10,
|
rating: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
workflow = await workflowRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: "BEFORE_EVENT",
|
||||||
|
time: 24,
|
||||||
|
timeUnit: "HOUR",
|
||||||
|
position: 0,
|
||||||
|
isActiveOnAll: false,
|
||||||
|
name: "Attendee SMS Reminder",
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
stepNumber: 1,
|
||||||
|
action: "SMS_ATTENDEE",
|
||||||
|
sendTo: null,
|
||||||
|
reminderBody:
|
||||||
|
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
|
||||||
|
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
|
||||||
|
template: "REMINDER",
|
||||||
|
numberRequired: true,
|
||||||
|
sender: "Cal",
|
||||||
|
numberVerificationPending: false,
|
||||||
|
includeCalendarEvent: false,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "event with attendee sms reminder",
|
||||||
|
slug: "event-with-attendee-sms-reminder",
|
||||||
|
length: 15,
|
||||||
|
bookingFields: [
|
||||||
|
{
|
||||||
|
name: "smsReminderNumber",
|
||||||
|
type: "phone",
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: String(workflow.id),
|
||||||
|
type: "workflow",
|
||||||
|
label: "Workflow",
|
||||||
|
editUrl: `/workflows/${workflow.id}`,
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "number_text_notifications",
|
||||||
|
defaultPlaceholder: "enter_phone_number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
disableStandardEmails: {
|
||||||
|
all: {
|
||||||
|
attendee: true,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
confirmation: {
|
||||||
|
host: true,
|
||||||
|
attendee: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflows: {
|
||||||
|
create: {
|
||||||
|
workflow: {
|
||||||
|
connect: {
|
||||||
|
id: workflow.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -583,7 +671,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -642,7 +730,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
describe("book by username and event type slug", () => {
|
describe("book by username and event type slug", () => {
|
||||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
|
||||||
const body = {
|
const body = {
|
||||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
attendee: {
|
attendee: {
|
||||||
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
language: "it",
|
language: "it",
|
||||||
},
|
},
|
||||||
location: googleMeetUrl,
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
customField: "customValue",
|
customField: "customValue",
|
||||||
},
|
},
|
||||||
@@ -2227,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("create booking with attendee sms reminder", () => {
|
||||||
|
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toEqual(
|
||||||
|
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be able create a booking if attendee sms reminder is provided", async () => {
|
||||||
|
const phoneNumber = "+37122222222";
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
phoneNumber,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(15);
|
||||||
|
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
|
||||||
|
expect(data.attendees.length).toEqual(1);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
phoneNumber: body.attendee.phoneNumber,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.bookingFieldsResponses).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: body.attendee.phoneNumber,
|
||||||
|
});
|
||||||
|
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
|
||||||
|
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
|
||||||
|
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
|
||||||
|
expect(dbWorkflowReminder?.method).toEqual("SMS");
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid response data");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||||
return (
|
return (
|
||||||
!Array.isArray(data) &&
|
!Array.isArray(data) &&
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
export const BookingReferences = [
|
||||||
|
"google_calendar",
|
||||||
|
"office365_calendar",
|
||||||
|
"daily_video",
|
||||||
|
"google_video",
|
||||||
|
"office365_video",
|
||||||
|
"zoom_video",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class BookingReferencesFilterInput_2024_08_13 {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Filter booking references by type",
|
||||||
|
required: false,
|
||||||
|
enum: BookingReferences,
|
||||||
|
example: "google_calendar",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(BookingReferences)
|
||||||
|
type?: (typeof BookingReferences)[number];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsNumber, IsString } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class BookingReference {
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The type of the booking reference",
|
||||||
|
})
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The event uid of the booking",
|
||||||
|
})
|
||||||
|
eventUid!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The id of the calendar the event is created in",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
destinationCalendarId!: string | null;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The id of the booking reference",
|
||||||
|
})
|
||||||
|
id!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferencesOutput_2024_08_13 {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The status of the request, always 'success' for successful responses",
|
||||||
|
example: SUCCESS_STATUS,
|
||||||
|
})
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Booking References",
|
||||||
|
type: [BookingReference],
|
||||||
|
})
|
||||||
|
data!: BookingReference[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 { 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";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferencesService_2024_08_13 {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
|
||||||
|
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBookingReferences(
|
||||||
|
bookingUid: string,
|
||||||
|
userId: number,
|
||||||
|
filter?: BookingReferencesFilterInput_2024_08_13
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.user?.id !== userId) {
|
||||||
|
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||||
|
|
||||||
|
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||||
|
|
||||||
|
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
|||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
@@ -19,6 +20,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
|
|||||||
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -58,13 +60,17 @@ type CreatedBooking = {
|
|||||||
start: string;
|
start: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventTypeBookingFieldSchema = z.object({
|
const eventTypeBookingFieldSchema = z
|
||||||
name: z.string(),
|
.object({
|
||||||
required: z.boolean(),
|
name: z.string(),
|
||||||
editable: z.string(),
|
required: z.boolean(),
|
||||||
});
|
editable: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
options: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||||
|
|
||||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||||
|
|
||||||
@@ -78,6 +84,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
@@ -169,36 +176,187 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
const bookingFieldsResponses: Record<string, unknown> = {
|
||||||
|
...body.bookingFieldsResponses,
|
||||||
|
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: body.attendee.phoneNumber,
|
||||||
|
};
|
||||||
if (!eventType?.bookingFields) {
|
if (!eventType?.bookingFields) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
|
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
|
||||||
|
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
|
||||||
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
||||||
.parse(eventType.bookingFields)
|
.parse(eventType.bookingFields)
|
||||||
.filter((field) => !field.editable.startsWith("system"));
|
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
|
||||||
|
|
||||||
if (!eventTypeBookingFields.length) {
|
if (!eventTypeBookingFields.length) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of eventTypeBookingFields) {
|
for (const eventTypeBookingField of eventTypeBookingFields) {
|
||||||
if (field.required && !(field.name in bookingFields)) {
|
if (
|
||||||
if (field.name === "attendeePhoneNumber") {
|
eventTypeBookingField.required &&
|
||||||
|
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
|
||||||
|
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
eventTypeBookingField.name === "attendeePhoneNumber" ||
|
||||||
|
eventTypeBookingField.name === "smsReminderNumber"
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
|
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
|
||||||
|
if (bookingFieldResponseValue !== undefined) {
|
||||||
|
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
|
||||||
|
let expectedBookingFieldResponseValueType = "";
|
||||||
|
let isValidType = false;
|
||||||
|
const eventTypeBookingFieldType = eventTypeBookingField.type;
|
||||||
|
|
||||||
|
switch (eventTypeBookingFieldType) {
|
||||||
|
case "phone":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "address":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
expectedBookingFieldResponseValueType = "number";
|
||||||
|
isValidType = bookingFieldResponseValueType === "number";
|
||||||
|
break;
|
||||||
|
case "textarea":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "multiemail":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
expectedBookingFieldResponseValueType = "boolean";
|
||||||
|
isValidType = bookingFieldResponseValueType === "boolean";
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
expectedBookingFieldResponseValueType = "string or number";
|
||||||
|
isValidType =
|
||||||
|
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValue = bookingFieldResponseValue as string | number;
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid option '${submittedValue}' for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "multiselect":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`One or more invalid options for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "checkbox":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`One or more invalid options for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "radio":
|
||||||
|
expectedBookingFieldResponseValueType = "string or number";
|
||||||
|
isValidType =
|
||||||
|
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValue = bookingFieldResponseValue as string | number;
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid option '${submittedValue}' for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "url":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// note(Lauris): by default pass the field if we have a missing "case" in the switch
|
||||||
|
isValidType = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isValidSingleOptionValue(
|
||||||
|
bookingFieldResponseValue: string | number,
|
||||||
|
eventTypeBookingFieldOptions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (eventTypeBookingFieldOptions.length === 0) {
|
||||||
|
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private areValidMultipleOptionValues(
|
||||||
|
bookingFieldResponseValues: (string | number)[],
|
||||||
|
eventTypeBookingFieldOptions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (eventTypeBookingFieldOptions.length === 0) {
|
||||||
|
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return bookingFieldResponseValues.every((submittedVal) =>
|
||||||
|
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createInstantBooking(
|
async createInstantBooking(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: CreateInstantBookingInput_2024_08_13,
|
body: CreateInstantBookingInput_2024_08_13,
|
||||||
@@ -374,7 +532,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
|
||||||
bookingListingByStatus: queryParams.status || [],
|
bookingListingByStatus: queryParams.status || [],
|
||||||
skip: queryParams.skip ?? 0,
|
skip: queryParams.skip ?? 0,
|
||||||
take: queryParams.take ?? 100,
|
take: queryParams.take ?? 100,
|
||||||
@@ -385,6 +543,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user,
|
user,
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
sort: this.inputService.transformGetBookingsSort(queryParams),
|
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||||
});
|
});
|
||||||
@@ -429,7 +588,29 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedBookings;
|
const skip = Math.abs(queryParams?.skip ?? 0);
|
||||||
|
const take = Math.abs(queryParams?.take ?? 100);
|
||||||
|
const itemsPerPage = take;
|
||||||
|
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
|
||||||
|
const currentPage = Math.floor(skip / itemsPerPage) + 1;
|
||||||
|
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
|
||||||
|
const hasPreviousPage = skip > 0;
|
||||||
|
return {
|
||||||
|
bookings: formattedBookings,
|
||||||
|
pagination: {
|
||||||
|
totalItems: fetchedBookings.totalCount,
|
||||||
|
// clamp remainingItems between 0 and totalCount
|
||||||
|
remainingItems: Math.min(
|
||||||
|
Math.max(fetchedBookings.totalCount - (skip + take), 0),
|
||||||
|
fetchedBookings.totalCount
|
||||||
|
),
|
||||||
|
itemsPerPage: itemsPerPage,
|
||||||
|
currentPage: currentPage,
|
||||||
|
totalPages: totalPages,
|
||||||
|
hasNextPage: hasNextPage,
|
||||||
|
hasPreviousPage: hasPreviousPage,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||||
@@ -569,6 +750,20 @@ export class BookingsService_2024_08_13 {
|
|||||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
|
||||||
|
if (!bookingBefore) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowUtc = DateTime.utc();
|
||||||
|
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||||
|
|
||||||
|
if (nowUtc < bookingStartTimeUtc) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const platformClientParams = bookingBefore?.eventTypeId
|
const platformClientParams = bookingBefore?.eventTypeId
|
||||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import {
|
||||||
|
eventTypeBookingFieldsSchema,
|
||||||
|
EventTypeWithOwnerAndTeam,
|
||||||
|
} from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
bookingResponsesSchema,
|
bookingResponsesSchema,
|
||||||
seatedBookingDataSchema,
|
seatedBookingDataSchema,
|
||||||
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
||||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||||
|
|
||||||
|
const needsSmsReminderNumber = eventType.bookingFields
|
||||||
|
? eventTypeBookingFieldsSchema
|
||||||
|
.parse(eventType.bookingFields)
|
||||||
|
.find((field) => field.name === "smsReminderNumber")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: startTime.toISO(),
|
start: startTime.toISO(),
|
||||||
end: endTime.toISO(),
|
end: endTime.toISO(),
|
||||||
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
name: inputBooking.attendee.name,
|
name: inputBooking.attendee.name,
|
||||||
email: attendeeEmail ?? "",
|
email: attendeeEmail ?? "",
|
||||||
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
|
||||||
guests,
|
guests,
|
||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
interface IBookingReference {
|
||||||
|
type: string;
|
||||||
|
uid: string;
|
||||||
|
id: number;
|
||||||
|
externalCalendarId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutputBookingReferencesService_2024_08_13 {
|
||||||
|
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
|
||||||
|
return bookingReferences.map((bookingReference) => ({
|
||||||
|
type: bookingReference.type,
|
||||||
|
eventUid: bookingReference.uid,
|
||||||
|
id: bookingReference.id,
|
||||||
|
destinationCalendarId: bookingReference?.externalCalendarId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
SeatedAttendee,
|
SeatedAttendee,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export const bookingResponsesSchema = z
|
export const bookingResponsesSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: APPLE_CALENDAR_ID,
|
appId: APPLE_CALENDAR_ID,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dav = new CalendarService({
|
const dav = new CalendarService({
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: ICS_CALENDAR,
|
appId: ICS_CALENDAR,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||||
|
|
||||||
type BaseEventType = {
|
type BaseEventType = {
|
||||||
length: number;
|
length: number;
|
||||||
|
|||||||
+70
-2
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
hideCalendarNotes: false,
|
hideCalendarNotes: false,
|
||||||
hideCalendarEventDetails: false,
|
hideCalendarEventDetails: false,
|
||||||
|
hideOrganizerEmail: false,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
|||||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||||
|
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||||
eventType.color = updatedEventType.color;
|
eventType.color = updatedEventType.color;
|
||||||
eventType.bookingFields = updatedEventType.bookingFields;
|
eventType.bookingFields = updatedEventType.bookingFields;
|
||||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1420,7 +1428,6 @@ describe("Event types Endpoints", () => {
|
|||||||
{
|
{
|
||||||
name: "location",
|
name: "location",
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
label: "",
|
|
||||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
editable: "system",
|
editable: "system",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1550,7 +1557,6 @@ describe("Event types Endpoints", () => {
|
|||||||
{
|
{
|
||||||
name: "location",
|
name: "location",
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
label: "",
|
|
||||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
editable: "system",
|
editable: "system",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1775,6 +1781,68 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("creating event type with input of another event type output", () => {
|
||||||
|
let firstCreatedEventType: EventTypeOutput_2024_06_14;
|
||||||
|
let secondCreatedEventType: EventTypeOutput_2024_06_14;
|
||||||
|
|
||||||
|
it("should create first event type", async () => {
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "first created coding class",
|
||||||
|
slug: "first-created-coding-class",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
type: "address",
|
||||||
|
address: "via volturno 10, Roma",
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.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.locations).toEqual(body.locations);
|
||||||
|
firstCreatedEventType = responseBody.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create second event type using first as input", async () => {
|
||||||
|
const body = {
|
||||||
|
...firstCreatedEventType,
|
||||||
|
title: "second created coding class",
|
||||||
|
slug: "second-created-coding-class",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.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);
|
||||||
|
secondCreatedEventType = responseBody.data;
|
||||||
|
|
||||||
|
const { id, title, slug, ...restFirst } = firstCreatedEventType;
|
||||||
|
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
|
||||||
|
expect(restFirst).toEqual(restSecond);
|
||||||
|
expect(id2).not.toEqual(id);
|
||||||
|
expect(title2).not.toEqual(title);
|
||||||
|
expect(slug2).not.toEqual(slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
|||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
@@ -19,7 +18,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||||
providers: [
|
providers: [
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
EventTypesService_2024_06_14,
|
EventTypesService_2024_06_14,
|
||||||
|
|||||||
+33
-13
@@ -5,6 +5,9 @@ import { ConferencingService } from "@/modules/conferencing/services/conferencin
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CONFERENCING_APPS } from "@calcom/platform-constants";
|
||||||
|
import { getUsersCredentials } from "@calcom/platform-libraries";
|
||||||
|
import { getApps } from "@calcom/platform-libraries/app-store";
|
||||||
import {
|
import {
|
||||||
transformBookingFieldsApiToInternal,
|
transformBookingFieldsApiToInternal,
|
||||||
transformLocationsApiToInternal,
|
transformLocationsApiToInternal,
|
||||||
@@ -50,8 +53,7 @@ interface ValidationContext {
|
|||||||
export class InputEventTypesService_2024_06_14 {
|
export class InputEventTypesService_2024_06_14 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly calendarsService: CalendarsService,
|
private readonly calendarsService: CalendarsService
|
||||||
private readonly conferencingService: ConferencingService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async transformAndValidateCreateEventTypeInput(
|
async transformAndValidateCreateEventTypeInput(
|
||||||
@@ -104,13 +106,6 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
|
||||||
{
|
|
||||||
type: "integration",
|
|
||||||
integration: "cal-video",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
lengthInMinutes,
|
lengthInMinutes,
|
||||||
lengthInMinutesOptions,
|
lengthInMinutesOptions,
|
||||||
@@ -130,10 +125,11 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
} = inputEventType;
|
} = inputEventType;
|
||||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||||
|
|
||||||
|
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...rest,
|
...rest,
|
||||||
length: lengthInMinutes,
|
length: lengthInMinutes,
|
||||||
locations: this.transformInputLocations(locations || defaultLocations),
|
locations: locationsTransformed,
|
||||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||||
durationLimits: bookingLimitsDuration
|
durationLimits: bookingLimitsDuration
|
||||||
@@ -228,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
||||||
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
||||||
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
||||||
|
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
|
||||||
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
||||||
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
||||||
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
||||||
@@ -237,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||||
systemCustomNameField || systemBeforeFieldName,
|
systemCustomNameField || systemBeforeFieldName,
|
||||||
systemCustomEmailField || systemBeforeFieldEmail,
|
systemCustomEmailField || systemBeforeFieldEmail,
|
||||||
systemBeforeFieldLocation,
|
systemCustomLocationField || systemBeforeFieldLocation,
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultFieldsAfter = [
|
const defaultFieldsAfter = [
|
||||||
@@ -278,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
field.name !== "title" &&
|
field.name !== "title" &&
|
||||||
field.name !== "notes" &&
|
field.name !== "notes" &&
|
||||||
field.name !== "guests" &&
|
field.name !== "guests" &&
|
||||||
field.name !== "rescheduleReason"
|
field.name !== "rescheduleReason" &&
|
||||||
|
field.name !== "location"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,10 +461,32 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
if (location.type === "integration") {
|
if (location.type === "integration") {
|
||||||
// cal-video is global, so we can skip this check
|
// cal-video is global, so we can skip this check
|
||||||
if (location.integration !== "cal-video") {
|
if (location.integration !== "cal-video") {
|
||||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
await this.checkAppIsValidAndConnected(user, location.integration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||||
|
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||||
|
if (!conferencingApps.includes(appSlug)) {
|
||||||
|
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appSlug === "office365-video") {
|
||||||
|
appSlug = "msteams";
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await getUsersCredentials(user);
|
||||||
|
|
||||||
|
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
|
||||||
|
|
||||||
|
const appLocation = foundApp?.appData?.location;
|
||||||
|
|
||||||
|
if (!foundApp || !appLocation) {
|
||||||
|
throw new BadRequestException(`${appSlug} not connected.`);
|
||||||
|
}
|
||||||
|
return foundApp.credential;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -87,6 +87,7 @@ type Input = Pick<
|
|||||||
| "destinationCalendar"
|
| "destinationCalendar"
|
||||||
| "useEventTypeDestinationCalendarEmail"
|
| "useEventTypeDestinationCalendarEmail"
|
||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
|
| "hideOrganizerEmail"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
seatsShowAttendees,
|
seatsShowAttendees,
|
||||||
useEventTypeDestinationCalendarEmail,
|
useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ if (process.env.SENTRY_DSN) {
|
|||||||
// Ensure to call this before requiring any other modules!
|
// Ensure to call this before requiring any other modules!
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: getEnv("SENTRY_DSN"),
|
dsn: getEnv("SENTRY_DSN"),
|
||||||
integrations: [nodeProfilingIntegration()],
|
integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()],
|
||||||
// Performance Monitoring
|
// Performance Monitoring
|
||||||
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
||||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { RateLimitType } from "@/lib/throttler-guard";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export const Throttle = Reflector.createDecorator<RateLimitType>();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getEnv } from "@/env";
|
import { getEnv } from "@/env";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
|||||||
ttl: z.number(),
|
ttl: z.number(),
|
||||||
blockDuration: z.number(),
|
blockDuration: z.number(),
|
||||||
});
|
});
|
||||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||||
|
|
||||||
const sixtySecondsMs = 60 * 1000;
|
const sixtySecondsMs = 60 * 1000;
|
||||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||||
const { context } = requestProps;
|
const { context } = requestProps;
|
||||||
|
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (throttleOptions) {
|
||||||
|
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||||
|
}
|
||||||
|
|
||||||
if (tracker.startsWith("api_key_")) {
|
if (tracker.startsWith("api_key_")) {
|
||||||
return this.handleApiKeyRequest(tracker, response);
|
return this.handleApiKeyRequest(tracker, response);
|
||||||
} else {
|
} else {
|
||||||
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
|
||||||
|
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
|
||||||
|
if (isBlocked) {
|
||||||
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||||
|
|
||||||
|
|||||||
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
|
|||||||
} else {
|
} else {
|
||||||
credentials = credentials.concat(teamAppCredentials);
|
credentials = credentials.concat(teamAppCredentials);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (slug !== "stripe") {
|
||||||
|
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
||||||
|
const { credentials: allCredentials } =
|
||||||
|
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
credentials,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
credentials = allCredentials;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
|
||||||
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
|
const enabledApps = await getEnabledAppsFromCredentials(
|
||||||
|
credentials as unknown as CredentialDataWithTeamName[],
|
||||||
{
|
{
|
||||||
user: {
|
where: { slug },
|
||||||
...user,
|
|
||||||
credentials,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
credentials = allCredentials;
|
|
||||||
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
|
|
||||||
where: { slug },
|
|
||||||
});
|
|
||||||
const apps = await Promise.all(
|
const apps = await Promise.all(
|
||||||
enabledApps
|
enabledApps
|
||||||
.filter(({ ...app }) => app.slug === slug)
|
.filter(({ ...app }) => app.slug === slug)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||||
@@ -5,6 +6,7 @@ import { BillingConfigService } from "@/modules/billing/services/billing.config.
|
|||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
|
|||||||
BillingRepository,
|
BillingRepository,
|
||||||
BillingProcessor,
|
BillingProcessor,
|
||||||
ManagedOrganizationsBillingService,
|
ManagedOrganizationsBillingService,
|
||||||
|
OAuthClientRepository,
|
||||||
|
BookingsRepository_2024_08_13,
|
||||||
],
|
],
|
||||||
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
|
|||||||
@@ -15,12 +15,21 @@ export class BillingRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
|
||||||
|
return this.dbRead.prisma.platformBilling.findFirst({
|
||||||
|
where: {
|
||||||
|
subscriptionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateTeamBilling(
|
async updateTeamBilling(
|
||||||
teamId: number,
|
teamId: number,
|
||||||
billingStart: number,
|
billingStart: number,
|
||||||
billingEnd: number,
|
billingEnd: number,
|
||||||
plan: PlatformPlan,
|
plan: PlatformPlan,
|
||||||
subscriptionId?: string
|
subscriptionId?: string,
|
||||||
|
priceId?: string
|
||||||
) {
|
) {
|
||||||
return this.dbWrite.prisma.platformBilling.update({
|
return this.dbWrite.prisma.platformBilling.update({
|
||||||
where: {
|
where: {
|
||||||
@@ -32,6 +41,7 @@ export class BillingRepository {
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
plan: plan.toString(),
|
plan: plan.toString(),
|
||||||
overdue: false,
|
overdue: false,
|
||||||
|
priceId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
|
Delete,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
@@ -96,6 +97,19 @@ export class BillingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete("/:organizationId/unsubscribe")
|
||||||
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||||
|
@MembershipRoles(["OWNER", "ADMIN"])
|
||||||
|
async cancelTeamSubscriptionInStripe(
|
||||||
|
@Param("organizationId") organizationId: number
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
await this.billingService.cancelTeamSubscription(organizationId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/webhook")
|
@Post("/webhook")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async stripeWebhook(
|
async stripeWebhook(
|
||||||
@@ -113,6 +127,9 @@ export class BillingController {
|
|||||||
case "customer.subscription.deleted":
|
case "customer.subscription.deleted":
|
||||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||||
break;
|
break;
|
||||||
|
case "invoice.created":
|
||||||
|
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||||
|
break;
|
||||||
case "invoice.payment_failed":
|
case "invoice.payment_failed":
|
||||||
await this.billingService.handleStripePaymentFailed(event);
|
await this.billingService.handleStripePaymentFailed(event);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { AppConfig } from "@/config/type";
|
import { AppConfig } from "@/config/type";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { PlatformPlan } from "@/modules/billing/types";
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { InjectQueue } from "@nestjs/bull";
|
import { InjectQueue } from "@nestjs/bull";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -29,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
private readonly billingRepository: BillingRepository,
|
private readonly billingRepository: BillingRepository,
|
||||||
private readonly configService: ConfigService<AppConfig>,
|
private readonly configService: ConfigService<AppConfig>,
|
||||||
private readonly billingConfigService: BillingConfigService,
|
private readonly billingConfigService: BillingConfigService,
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
||||||
) {
|
) {
|
||||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||||
@@ -118,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||||
const billingCycleStart = DateTime.now().get("day");
|
const billingCycleStart = DateTime.now().get("day");
|
||||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||||
|
|
||||||
@@ -131,6 +138,25 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setPerActiveUserSubscriptionForTeam(
|
||||||
|
teamId: number,
|
||||||
|
subscriptionId: string,
|
||||||
|
plan: PlatformPlan,
|
||||||
|
priceId: string
|
||||||
|
) {
|
||||||
|
const billingCycleStart = DateTime.now().get("day");
|
||||||
|
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||||
|
|
||||||
|
return this.billingRepository.updateTeamBilling(
|
||||||
|
teamId,
|
||||||
|
billingCycleStart,
|
||||||
|
billingCycleEnd,
|
||||||
|
plan,
|
||||||
|
subscriptionId,
|
||||||
|
priceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
const teamId = subscription?.metadata?.teamId;
|
const teamId = subscription?.metadata?.teamId;
|
||||||
@@ -203,9 +229,19 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
|
||||||
|
|
||||||
if (checkoutSession.mode === "subscription") {
|
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
|
||||||
await this.setSubscriptionForTeam(
|
await this.setPerActiveUserSubscriptionForTeam(
|
||||||
|
teamId,
|
||||||
|
checkoutSession.subscription as string,
|
||||||
|
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan],
|
||||||
|
checkoutSession.metadata?.priceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkoutSession.mode === "subscription" && !isPriceIdPresent) {
|
||||||
|
await this.setPerBookingSubscriptionForTeam(
|
||||||
teamId,
|
teamId,
|
||||||
checkoutSession.subscription as string,
|
checkoutSession.subscription as string,
|
||||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||||
@@ -219,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||||
|
|
||||||
|
if (!subscriptionId) {
|
||||||
|
throw new NotFoundException("No subscription found for team");
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
|
||||||
|
|
||||||
|
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
|
||||||
|
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
|
||||||
|
subscriptionId,
|
||||||
|
new Date(invoice.period_start * 1000),
|
||||||
|
new Date(invoice.period_end * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
|
||||||
|
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!managedUsersEmails) return 0;
|
||||||
|
|
||||||
|
if (!invoiceStart || !invoiceEnd) {
|
||||||
|
this.logger.log("Invoice period start or end date is null");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost(
|
||||||
|
subscriptionId,
|
||||||
|
invoiceStart,
|
||||||
|
invoiceEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email);
|
||||||
|
const notActiveHostEmails = managedUsersEmails
|
||||||
|
.filter((email) => !activeHostEmails.includes(email.email))
|
||||||
|
.map((email) => email.email);
|
||||||
|
|
||||||
|
if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length;
|
||||||
|
|
||||||
|
const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee(
|
||||||
|
notActiveHostEmails,
|
||||||
|
invoiceStart,
|
||||||
|
invoiceEnd
|
||||||
|
);
|
||||||
|
|
||||||
|
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
|
||||||
|
}
|
||||||
|
|
||||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
|
|
||||||
@@ -262,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
proration_behavior: "create_prorations",
|
proration_behavior: "create_prorations",
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.setSubscriptionForTeam(
|
await this.setPerBookingSubscriptionForTeam(
|
||||||
teamId,
|
teamId,
|
||||||
teamWithBilling?.platformBilling?.subscriptionId,
|
teamWithBilling?.platformBilling?.subscriptionId,
|
||||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||||
@@ -313,6 +410,28 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelTeamSubscription(teamId: number) {
|
||||||
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
|
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new NotFoundException("No customer id found for team in Stripe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||||
|
throw new NotFoundException("Team plan not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.stripeService
|
||||||
|
.getStripe()
|
||||||
|
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log(error, "error while cancelling team subscription in stripe");
|
||||||
|
throw new BadRequestException("Failed to cancel team subscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum PlatformPlan {
|
|||||||
ESSENTIALS = "ESSENTIALS",
|
ESSENTIALS = "ESSENTIALS",
|
||||||
SCALE = "SCALE",
|
SCALE = "SCALE",
|
||||||
ENTERPRISE = "ENTERPRISE",
|
ENTERPRISE = "ENTERPRISE",
|
||||||
|
PER_ACTIVE_USER = "PER_ACTIVE_USER",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
|
||||||
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
|
|||||||
import { RouterModule } from "@/modules/router/router.module";
|
import { RouterModule } from "@/modules/router/router.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||||
|
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
|
||||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
|||||||
OrganizationsUsersBookingsModule,
|
OrganizationsUsersBookingsModule,
|
||||||
OrganizationsBookingsModule,
|
OrganizationsBookingsModule,
|
||||||
OrganizationsRoutingFormsModule,
|
OrganizationsRoutingFormsModule,
|
||||||
|
VerifiedResourcesModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import type { DB } from "@calcom/kysely/types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KyselyReadService implements OnModuleDestroy {
|
||||||
|
private logger = new Logger("KyselyReadService");
|
||||||
|
|
||||||
|
public kysely: Kysely<DB>;
|
||||||
|
|
||||||
|
constructor(readonly configService: ConfigService) {
|
||||||
|
const dbUrl = configService.get("db.readUrl", { infer: true });
|
||||||
|
const pool = new Pool({ connectionString: dbUrl });
|
||||||
|
|
||||||
|
// 3. Create the Dialect, passing the configured pool instance
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: pool, // Use the pool instance created above
|
||||||
|
});
|
||||||
|
this.kysely = new Kysely<DB>({
|
||||||
|
dialect,
|
||||||
|
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
await this.kysely.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import type { DB } from "@calcom/kysely/types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KyselyWriteService implements OnModuleDestroy {
|
||||||
|
private logger = new Logger("KyselyWriteService");
|
||||||
|
|
||||||
|
public kysely: Kysely<DB>;
|
||||||
|
|
||||||
|
constructor(readonly configService: ConfigService) {
|
||||||
|
const dbUrl = configService.get("db.writeUrl", { infer: true });
|
||||||
|
const pool = new Pool({ connectionString: dbUrl });
|
||||||
|
|
||||||
|
// 3. Create the Dialect, passing the configured pool instance
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: pool, // Use the pool instance created above
|
||||||
|
});
|
||||||
|
this.kysely = new Kysely<DB>({
|
||||||
|
dialect,
|
||||||
|
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
await this.kysely.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
|
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [KyselyReadService, KyselyWriteService],
|
||||||
|
exports: [KyselyReadService, KyselyWriteService],
|
||||||
|
})
|
||||||
|
export class KyselyModule {}
|
||||||
@@ -63,6 +63,7 @@ export class MembershipsRepository {
|
|||||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||||
const membership = await this.dbRead.prisma.membership.create({
|
const membership = await this.dbRead.prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
role,
|
role,
|
||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
|
|||||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||||
|
|
||||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class OrganizationsBookingsController {
|
|||||||
): Promise<GetBookingsOutput_2024_08_13> {
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
const { userIds, ...restParams } = queryParams;
|
const { userIds, ...restParams } = queryParams;
|
||||||
|
|
||||||
const bookings = await this.bookingsService.getBookings(
|
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||||
{ ...restParams },
|
{ ...restParams },
|
||||||
{ email: user.email, id: user.id, orgId },
|
{ email: user.email, id: user.id, orgId },
|
||||||
userIds
|
userIds
|
||||||
@@ -54,6 +54,7 @@ export class OrganizationsBookingsController {
|
|||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: bookings,
|
data: bookings,
|
||||||
|
pagination,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||||
expect(data.color).toEqual(body.color);
|
expect(data.color).toEqual(body.color);
|
||||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||||
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||||
expect(data.color).toEqual(body.color);
|
expect(data.color).toEqual(body.color);
|
||||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||||
|
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
@@ -27,7 +28,8 @@ export class InputOrganizationsEventTypesService {
|
|||||||
private readonly teamsRepository: TeamsRepository,
|
private readonly teamsRepository: TeamsRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
private readonly conferencingService: OrganizationsConferencingService
|
private readonly conferencingService: OrganizationsConferencingService,
|
||||||
|
private readonly conferencingRepository: ConferencingRepository
|
||||||
) {}
|
) {}
|
||||||
async transformAndValidateCreateTeamEventTypeInput(
|
async transformAndValidateCreateTeamEventTypeInput(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -306,6 +308,22 @@ export class InputOrganizationsEventTypesService {
|
|||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkAppIsValidAndConnected(teamId: number, app: string) {
|
||||||
|
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||||
|
if (!conferencingApps.includes(app)) {
|
||||||
|
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||||
|
}
|
||||||
|
if (app === "office365-video") {
|
||||||
|
app = "msteams";
|
||||||
|
}
|
||||||
|
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new BadRequestException(`${app} not connected.`);
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type Input = Pick<
|
|||||||
| "eventName"
|
| "eventName"
|
||||||
| "useEventTypeDestinationCalendarEmail"
|
| "useEventTypeDestinationCalendarEmail"
|
||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
|
| "hideOrganizerEmail"
|
||||||
| "team"
|
| "team"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
+196
-10
@@ -6,6 +6,7 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
|
|||||||
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
|
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
|
||||||
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
|
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
|
||||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||||
|
import { OrgUserAttribute } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||||
@@ -14,17 +15,16 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { User } from "@prisma/client";
|
import { Attribute, AttributeOption, User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
|
||||||
import { Membership, Team } from "@calcom/prisma/client";
|
import { Membership, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Memberships Endpoints", () => {
|
describe("Organizations Memberships Endpoints", () => {
|
||||||
@@ -33,7 +33,8 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||||
|
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let membership: Membership;
|
let membership: Membership;
|
||||||
@@ -49,6 +50,17 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
|
|
||||||
let userToInviteViaApi: User;
|
let userToInviteViaApi: User;
|
||||||
|
|
||||||
|
let textAttribute: Attribute;
|
||||||
|
let multiSelectAttribute: Attribute;
|
||||||
|
let numberAttribute: Attribute;
|
||||||
|
let singleSelectAttribute: Attribute;
|
||||||
|
|
||||||
|
let textAttributeOption: AttributeOption;
|
||||||
|
let multiSelectAttributeOption: AttributeOption;
|
||||||
|
let multiSelectAttributeOption2: AttributeOption;
|
||||||
|
let numberAttributeOption: AttributeOption;
|
||||||
|
let singleSelectAttributeOption: AttributeOption;
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
some: "key",
|
some: "key",
|
||||||
};
|
};
|
||||||
@@ -64,7 +76,8 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
@@ -91,16 +104,42 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
membership = await membershipsRepositoryFixture.create({
|
await setupAttributes();
|
||||||
|
|
||||||
|
membership = await membershipRepositoryFixture.create({
|
||||||
role: "ADMIN",
|
role: "ADMIN",
|
||||||
user: { connect: { id: user.id } },
|
user: { connect: { id: user.id } },
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
membership2 = await membershipsRepositoryFixture.create({
|
membership2 = await membershipRepositoryFixture.create({
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
user: { connect: { id: user2.id } },
|
user: { connect: { id: user2.id } },
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
|
AttributeToUser: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
attributeOption: { connect: { id: textAttributeOption.id } },
|
||||||
|
weight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeOption: { connect: { id: multiSelectAttributeOption.id } },
|
||||||
|
weight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeOption: { connect: { id: multiSelectAttributeOption2.id } },
|
||||||
|
weight: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeOption: { connect: { id: numberAttributeOption.id } },
|
||||||
|
weight: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeOption: { connect: { id: singleSelectAttributeOption.id } },
|
||||||
|
weight: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -109,6 +148,87 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupAttributes() {
|
||||||
|
textAttribute = await attributesRepositoryFixture.create({
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "TEXT",
|
||||||
|
name: "team",
|
||||||
|
slug: `team-${randomString()}`,
|
||||||
|
enabled: true,
|
||||||
|
usersCanEditRelation: false,
|
||||||
|
isWeightsEnabled: false,
|
||||||
|
isLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
multiSelectAttribute = await attributesRepositoryFixture.create({
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "MULTI_SELECT",
|
||||||
|
name: "skills",
|
||||||
|
slug: `skills-${randomString()}`,
|
||||||
|
enabled: true,
|
||||||
|
usersCanEditRelation: false,
|
||||||
|
isWeightsEnabled: false,
|
||||||
|
isLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberAttribute = await attributesRepositoryFixture.create({
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "NUMBER",
|
||||||
|
name: "age",
|
||||||
|
slug: `age-${randomString()}`,
|
||||||
|
enabled: true,
|
||||||
|
usersCanEditRelation: false,
|
||||||
|
isWeightsEnabled: false,
|
||||||
|
isLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
singleSelectAttribute = await attributesRepositoryFixture.create({
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "SINGLE_SELECT",
|
||||||
|
name: "frontend",
|
||||||
|
slug: `frontend-${randomString()}`,
|
||||||
|
enabled: true,
|
||||||
|
usersCanEditRelation: false,
|
||||||
|
isWeightsEnabled: false,
|
||||||
|
isLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
textAttributeOption = await attributesRepositoryFixture.createOption({
|
||||||
|
attribute: { connect: { id: textAttribute.id } },
|
||||||
|
value: "coders",
|
||||||
|
slug: "coders",
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
multiSelectAttributeOption = await attributesRepositoryFixture.createOption({
|
||||||
|
attribute: { connect: { id: multiSelectAttribute.id } },
|
||||||
|
value: "javascript",
|
||||||
|
slug: "javascript",
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
multiSelectAttributeOption2 = await attributesRepositoryFixture.createOption({
|
||||||
|
attribute: { connect: { id: multiSelectAttribute.id } },
|
||||||
|
value: "typescript",
|
||||||
|
slug: "typescript",
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberAttributeOption = await attributesRepositoryFixture.createOption({
|
||||||
|
attribute: { connect: { id: numberAttribute.id } },
|
||||||
|
value: "18",
|
||||||
|
slug: "18",
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
singleSelectAttributeOption = await attributesRepositoryFixture.createOption({
|
||||||
|
attribute: { connect: { id: singleSelectAttribute.id } },
|
||||||
|
value: "yes",
|
||||||
|
slug: "yes",
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
expect(userRepositoryFixture).toBeDefined();
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
expect(organizationsRepositoryFixture).toBeDefined();
|
expect(organizationsRepositoryFixture).toBeDefined();
|
||||||
@@ -139,9 +259,54 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
expect(responseBody.data[1].user.email).toEqual(user2.email);
|
expect(responseBody.data[1].user.email).toEqual(user2.email);
|
||||||
expect(responseBody.data[1].user.username).toEqual(user2.username);
|
expect(responseBody.data[1].user.username).toEqual(user2.username);
|
||||||
expect(responseBody.data[1].teamId).toEqual(org.id);
|
expect(responseBody.data[1].teamId).toEqual(org.id);
|
||||||
|
userHasCorrectAttributes(responseBody.data[1].attributes);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function userHasCorrectAttributes(attributes: OrgUserAttribute[]) {
|
||||||
|
expect(attributes.length).toEqual(4);
|
||||||
|
const responseNumberAttribute = attributes.find((attr) => attr.type === "number");
|
||||||
|
const responseSingleSelectAttribute = attributes.find((attr) => attr.type === "singleSelect");
|
||||||
|
const responseMultiSelectAttribute = attributes.find((attr) => attr.type === "multiSelect");
|
||||||
|
const responseTextAttribute = attributes.find((attr) => attr.type === "text");
|
||||||
|
expect(responseNumberAttribute).toEqual({
|
||||||
|
id: numberAttribute.id,
|
||||||
|
name: numberAttribute.name,
|
||||||
|
optionId: numberAttributeOption.id,
|
||||||
|
option: +numberAttributeOption.value,
|
||||||
|
type: "number",
|
||||||
|
});
|
||||||
|
expect(responseSingleSelectAttribute).toEqual({
|
||||||
|
id: singleSelectAttribute.id,
|
||||||
|
name: singleSelectAttribute.name,
|
||||||
|
optionId: singleSelectAttributeOption.id,
|
||||||
|
option: singleSelectAttributeOption.value,
|
||||||
|
type: "singleSelect",
|
||||||
|
});
|
||||||
|
expect(responseMultiSelectAttribute).toEqual({
|
||||||
|
id: multiSelectAttribute.id,
|
||||||
|
name: multiSelectAttribute.name,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
optionId: multiSelectAttributeOption2.id,
|
||||||
|
option: multiSelectAttributeOption2.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
optionId: multiSelectAttributeOption.id,
|
||||||
|
option: multiSelectAttributeOption.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: "multiSelect",
|
||||||
|
});
|
||||||
|
expect(responseTextAttribute).toEqual({
|
||||||
|
id: textAttribute.id,
|
||||||
|
name: textAttribute.name,
|
||||||
|
optionId: textAttributeOption.id,
|
||||||
|
option: textAttributeOption.value,
|
||||||
|
type: "text",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("should get all the memberships of the org paginated", async () => {
|
it("should get all the memberships of the org paginated", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
||||||
@@ -182,6 +347,25 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should get the membership of the org", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/memberships/${membership2.id}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
const responseBody: GetOrgMembership = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data.id).toEqual(membership2.id);
|
||||||
|
expect(responseBody.data.userId).toEqual(user2.id);
|
||||||
|
expect(responseBody.data.role).toEqual("MEMBER");
|
||||||
|
expect(responseBody.data.user.bio).toEqual(bio);
|
||||||
|
expect(responseBody.data.user.metadata).toEqual(metadata);
|
||||||
|
expect(responseBody.data.user.email).toEqual(user2.email);
|
||||||
|
expect(responseBody.data.user.username).toEqual(user2.username);
|
||||||
|
expect(responseBody.data.teamId).toEqual(org.id);
|
||||||
|
userHasCorrectAttributes(responseBody.data.attributes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should create the membership of the org", async () => {
|
it("should create the membership of the org", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/organizations/${org.id}/memberships`)
|
.post(`/v2/organizations/${org.id}/memberships`)
|
||||||
@@ -259,7 +443,8 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||||
|
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let membership: Membership;
|
let membership: Membership;
|
||||||
@@ -277,7 +462,8 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
@@ -289,7 +475,7 @@ describe("Organizations Memberships Endpoints", () => {
|
|||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
membership = await membershipsRepositoryFixture.create({
|
membership = await membershipRepositoryFixture.create({
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
user: { connect: { id: user.id } },
|
user: { connect: { id: user.id } },
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
|
|||||||
+5
-9
@@ -20,7 +20,6 @@ import { GetAllOrgMemberships } from "@/modules/organizations/memberships/output
|
|||||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { SkipTakePagination } from "@calcom/platform-types";
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
@@ -70,9 +68,7 @@ export class OrganizationsMembershipsController {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: memberships.map((membership) =>
|
data: memberships,
|
||||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +84,7 @@ export class OrganizationsMembershipsController {
|
|||||||
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
data: membership,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +101,7 @@ export class OrganizationsMembershipsController {
|
|||||||
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
|
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
data: membership,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +118,7 @@ export class OrganizationsMembershipsController {
|
|||||||
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
|
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
data: membership,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +140,7 @@ export class OrganizationsMembershipsController {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
data: membership,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+59
-6
@@ -4,8 +4,31 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
||||||
|
|
||||||
|
export type DbOrgMembership = Awaited<ReturnType<OrganizationsMembershipRepository["findOrgMembership"]>>;
|
||||||
|
|
||||||
|
const attributeToUserSelect = {
|
||||||
|
createdByDSyncId: true,
|
||||||
|
weight: true,
|
||||||
|
attributeOption: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
value: true,
|
||||||
|
slug: true,
|
||||||
|
attribute: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.AttributeToUserSelect;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsMembershipRepository {
|
export class OrganizationsMembershipRepository {
|
||||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
@@ -16,7 +39,12 @@ export class OrganizationsMembershipRepository {
|
|||||||
id: membershipId,
|
id: membershipId,
|
||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
},
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +54,12 @@ export class OrganizationsMembershipRepository {
|
|||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +69,12 @@ export class OrganizationsMembershipRepository {
|
|||||||
id: membershipId,
|
id: membershipId,
|
||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
},
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +83,24 @@ export class OrganizationsMembershipRepository {
|
|||||||
create: { ...data, teamId: organizationId },
|
create: { ...data, teamId: organizationId },
|
||||||
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
|
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
|
||||||
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
|
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||||
return this.dbWrite.prisma.membership.update({
|
return this.dbWrite.prisma.membership.update({
|
||||||
data: { ...data },
|
data: { ...data },
|
||||||
where: { id: membershipId, teamId: organizationId },
|
where: { id: membershipId, teamId: organizationId },
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +109,12 @@ export class OrganizationsMembershipRepository {
|
|||||||
where: {
|
where: {
|
||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
},
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: {
|
||||||
|
user: { select: MembershipUserSelect },
|
||||||
|
AttributeToUser: {
|
||||||
|
select: attributeToUserSelect,
|
||||||
|
},
|
||||||
|
},
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
|
|||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: TeamMembershipOutput,
|
type: OrganizationMembershipOutput,
|
||||||
})
|
})
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => TeamMembershipOutput)
|
@Type(() => OrganizationMembershipOutput)
|
||||||
data!: TeamMembershipOutput;
|
data!: OrganizationMembershipOutput;
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsEnum } from "class-validator";
|
import { IsEnum } from "class-validator";
|
||||||
|
|
||||||
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
|
|||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
data!: TeamMembershipOutput;
|
data!: OrganizationMembershipOutput;
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
|
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
|
||||||
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
|
|||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: TeamMembershipOutput,
|
type: OrganizationMembershipOutput,
|
||||||
})
|
})
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => TeamMembershipOutput)
|
@Type(() => OrganizationMembershipOutput)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
data!: TeamMembershipOutput[];
|
data!: OrganizationMembershipOutput[];
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
@@ -11,10 +11,10 @@ export class GetOrgMembership {
|
|||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: TeamMembershipOutput,
|
type: OrganizationMembershipOutput,
|
||||||
})
|
})
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => TeamMembershipOutput)
|
@Type(() => OrganizationMembershipOutput)
|
||||||
data!: TeamMembershipOutput;
|
data!: OrganizationMembershipOutput;
|
||||||
}
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||||
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
class BaseAttribute {
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextAttribute extends BaseAttribute {
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
type!: "text";
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
option!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
optionId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NumberAttribute extends BaseAttribute {
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
type!: "number";
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
option!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
optionId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SingleSelectAttribute extends BaseAttribute {
|
||||||
|
@IsString()
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
type!: "singleSelect";
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
option!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
optionId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiSelectAttributeOption {
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
optionId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
option!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MultiSelectAttribute extends BaseAttribute {
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
type!: "multiSelect";
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty()
|
||||||
|
options!: MultiSelectAttributeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgUserAttribute = TextAttribute | NumberAttribute | SingleSelectAttribute | MultiSelectAttribute;
|
||||||
|
|
||||||
|
@ApiExtraModels(BaseAttribute, TextAttribute, NumberAttribute, SingleSelectAttribute, MultiSelectAttribute)
|
||||||
|
export class OrganizationMembershipOutput extends TeamMembershipOutput {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty({
|
||||||
|
required: true,
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(TextAttribute) },
|
||||||
|
{ $ref: getSchemaPath(NumberAttribute) },
|
||||||
|
{ $ref: getSchemaPath(SingleSelectAttribute) },
|
||||||
|
{ $ref: getSchemaPath(MultiSelectAttribute) },
|
||||||
|
],
|
||||||
|
type: "array",
|
||||||
|
})
|
||||||
|
@Type(() => Object)
|
||||||
|
attributes!: OrgUserAttribute[];
|
||||||
|
}
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
|
|||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: TeamMembershipOutput,
|
type: OrganizationMembershipOutput,
|
||||||
})
|
})
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => TeamMembershipOutput)
|
@Type(() => OrganizationMembershipOutput)
|
||||||
data!: TeamMembershipOutput;
|
data!: OrganizationMembershipOutput;
|
||||||
}
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import { DbOrgMembership } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||||
|
import {
|
||||||
|
MultiSelectAttribute,
|
||||||
|
NumberAttribute,
|
||||||
|
OrganizationMembershipOutput,
|
||||||
|
OrgUserAttribute,
|
||||||
|
SingleSelectAttribute,
|
||||||
|
TextAttribute,
|
||||||
|
} from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
import { groupMembershipAttributes } from "@calcom/platform-libraries";
|
||||||
|
import type { GroupedAttribute } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationsMembershipOutputService {
|
||||||
|
getOrgMembershipsOutput(memberships: NonNullable<DbOrgMembership>[]) {
|
||||||
|
return memberships.map((membership) => this.getOrgMembershipOutput(membership));
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrgMembershipOutput(organizationMembership: NonNullable<DbOrgMembership>) {
|
||||||
|
const { AttributeToUser, ...orgMembership } = organizationMembership;
|
||||||
|
|
||||||
|
const groupedMembershipAttributes: GroupedAttribute[] = groupMembershipAttributes(AttributeToUser);
|
||||||
|
const attributesOutput = this.getAttributesOutput(groupedMembershipAttributes);
|
||||||
|
|
||||||
|
return plainToClass(OrganizationMembershipOutput, { ...orgMembership, attributes: attributesOutput });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAttributesOutput(attributes: GroupedAttribute[]): OrgUserAttribute[] {
|
||||||
|
return attributes.map((attribute) => {
|
||||||
|
switch (attribute.type) {
|
||||||
|
case "TEXT":
|
||||||
|
return this.getTextAttributeOutput(attribute);
|
||||||
|
case "NUMBER":
|
||||||
|
return this.getNumberAttributeOutput(attribute);
|
||||||
|
case "SINGLE_SELECT":
|
||||||
|
return this.getSingleSelectAttributeOutput(attribute);
|
||||||
|
case "MULTI_SELECT":
|
||||||
|
return this.getMultiSelectAttributeOutput(attribute);
|
||||||
|
default:
|
||||||
|
return this.getTextAttributeOutput(attribute);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTextAttributeOutput(attribute: GroupedAttribute): TextAttribute {
|
||||||
|
return {
|
||||||
|
id: attribute.id,
|
||||||
|
name: attribute.name,
|
||||||
|
optionId: attribute.options[0].id,
|
||||||
|
option: attribute.options[0].value,
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNumberAttributeOutput(attribute: GroupedAttribute): NumberAttribute {
|
||||||
|
return {
|
||||||
|
id: attribute.id,
|
||||||
|
name: attribute.name,
|
||||||
|
optionId: attribute.options[0].id,
|
||||||
|
option: +attribute.options[0].value,
|
||||||
|
type: "number",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSingleSelectAttributeOutput(attribute: GroupedAttribute): SingleSelectAttribute {
|
||||||
|
return {
|
||||||
|
id: attribute.id,
|
||||||
|
name: attribute.name,
|
||||||
|
optionId: attribute.options[0].id,
|
||||||
|
option: attribute.options[0].value,
|
||||||
|
type: "singleSelect",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMultiSelectAttributeOutput(attribute: GroupedAttribute): MultiSelectAttribute {
|
||||||
|
return {
|
||||||
|
id: attribute.id,
|
||||||
|
name: attribute.name,
|
||||||
|
options: attribute.options.map((option: GroupedAttribute["options"][number]) => ({
|
||||||
|
optionId: option.id,
|
||||||
|
option: option.value,
|
||||||
|
})),
|
||||||
|
type: "multiSelect",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-8
@@ -1,19 +1,30 @@
|
|||||||
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
||||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
|
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
|
||||||
|
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsMembershipService {
|
export class OrganizationsMembershipService {
|
||||||
constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
|
constructor(
|
||||||
|
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
|
||||||
|
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
|
||||||
|
) {}
|
||||||
|
|
||||||
async getOrgMembership(organizationId: number, membershipId: number) {
|
async getOrgMembership(organizationId: number, membershipId: number) {
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||||
organizationId,
|
organizationId,
|
||||||
membershipId
|
membershipId
|
||||||
);
|
);
|
||||||
return membership;
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Membership with id ${membershipId} within organization id ${organizationId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||||
@@ -21,7 +32,13 @@ export class OrganizationsMembershipService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
return membership;
|
if (!membership) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Membership for user with id ${userId} within organization id ${organizationId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
|
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
|
||||||
@@ -30,7 +47,7 @@ export class OrganizationsMembershipService {
|
|||||||
skip,
|
skip,
|
||||||
take
|
take
|
||||||
);
|
);
|
||||||
return memberships;
|
return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
||||||
@@ -38,7 +55,7 @@ export class OrganizationsMembershipService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
membershipId
|
membershipId
|
||||||
);
|
);
|
||||||
return membership;
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||||
@@ -47,11 +64,11 @@ export class OrganizationsMembershipService {
|
|||||||
membershipId,
|
membershipId,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return membership;
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
|
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
|
||||||
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
|
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
|
||||||
return membership;
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
|||||||
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
|
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
|
||||||
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
|
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
|
||||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||||
|
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||||
@@ -98,6 +99,7 @@ import { Module } from "@nestjs/common";
|
|||||||
EmailService,
|
EmailService,
|
||||||
OrganizationsMembershipRepository,
|
OrganizationsMembershipRepository,
|
||||||
OrganizationsMembershipService,
|
OrganizationsMembershipService,
|
||||||
|
OrganizationsMembershipOutputService,
|
||||||
OrganizationsEventTypesService,
|
OrganizationsEventTypesService,
|
||||||
InputOrganizationsEventTypesService,
|
InputOrganizationsEventTypesService,
|
||||||
OutputOrganizationsEventTypesService,
|
OutputOrganizationsEventTypesService,
|
||||||
|
|||||||
+2
@@ -4,6 +4,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
|||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||||
|
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||||
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
|
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
|
||||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
|||||||
ManagedOrganizationsBillingService,
|
ManagedOrganizationsBillingService,
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
OrganizationsMembershipService,
|
OrganizationsMembershipService,
|
||||||
|
OrganizationsMembershipOutputService,
|
||||||
OrganizationsMembershipRepository,
|
OrganizationsMembershipRepository,
|
||||||
ManagedOrganizationsOutputService,
|
ManagedOrganizationsOutputService,
|
||||||
],
|
],
|
||||||
|
|||||||
+40
-30
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
|
|
||||||
where: {
|
|
||||||
formId: routingForm.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.team.delete({
|
|
||||||
where: {
|
|
||||||
id: team.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.team.delete({
|
|
||||||
where: {
|
|
||||||
id: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||||
it("should not get routing form responses for non existing org", async () => {
|
it("should not get routing form responses for non existing org", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||||
|
where: {
|
||||||
|
id: routingFormResponse.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||||
|
where: {
|
||||||
|
id: routingFormResponse2.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.team.delete({
|
||||||
|
where: {
|
||||||
|
id: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.team.delete({
|
||||||
|
where: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+41
-3
@@ -1,13 +1,18 @@
|
|||||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||||
|
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 { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import {
|
import {
|
||||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||||
OPTIONAL_API_KEY_HEADER,
|
OPTIONAL_API_KEY_HEADER,
|
||||||
|
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||||
} from "@/lib/docs/headers";
|
} from "@/lib/docs/headers";
|
||||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
|||||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
|
||||||
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
|||||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
export class OrganizationsTeamsBookingsController {
|
export class OrganizationsTeamsBookingsController {
|
||||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
constructor(
|
||||||
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
|
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({ summary: "Get organization team bookings" })
|
@ApiOperation({ summary: "Get organization team bookings" })
|
||||||
@@ -46,7 +54,7 @@ export class OrganizationsTeamsBookingsController {
|
|||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<GetBookingsOutput_2024_08_13> {
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
const bookings = await this.bookingsService.getBookings(
|
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||||
{ ...queryParams, teamId },
|
{ ...queryParams, teamId },
|
||||||
{ email: user.email, id: user.id, orgId }
|
{ email: user.email, id: user.id, orgId }
|
||||||
);
|
);
|
||||||
@@ -54,6 +62,36 @@ export class OrganizationsTeamsBookingsController {
|
|||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: bookings,
|
data: bookings,
|
||||||
|
pagination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid/references")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@UseGuards(
|
||||||
|
ApiAuthGuard,
|
||||||
|
BookingUidGuard,
|
||||||
|
IsOrgGuard,
|
||||||
|
RolesGuard,
|
||||||
|
IsTeamInOrg,
|
||||||
|
PlatformPlanGuard,
|
||||||
|
IsAdminAPIEnabledGuard
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get 'Booking References' for a booking",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getBookingReferences(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||||
|
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||||
|
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookingReferences,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-1
@@ -1,4 +1,8 @@
|
|||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
||||||
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
||||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
providers: [
|
||||||
|
OrganizationsRepository,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
BookingReferencesService_2024_08_13,
|
||||||
|
BookingReferencesRepository_2024_08_13,
|
||||||
|
BookingsRepository_2024_08_13,
|
||||||
|
OutputBookingReferencesService_2024_08_13,
|
||||||
|
],
|
||||||
controllers: [OrganizationsTeamsBookingsController],
|
controllers: [OrganizationsTeamsBookingsController],
|
||||||
})
|
})
|
||||||
export class OrganizationsTeamsBookingsModule {}
|
export class OrganizationsTeamsBookingsModule {}
|
||||||
+5
-1
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
|
|||||||
|
|
||||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||||
return this.dbWrite.prisma.membership.create({
|
return this.dbWrite.prisma.membership.create({
|
||||||
data: { ...data, teamId: teamId },
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
teamId: teamId,
|
||||||
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: { user: { select: MembershipUserSelect } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
|
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let orgTeam: Team;
|
let orgTeam: Team;
|
||||||
let routingFormResponseId: string;
|
let routingFormResponseId: number;
|
||||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||||
let user: User;
|
let user: User;
|
||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
let routingFormId: string;
|
let routingFormId: string;
|
||||||
const routingFormResponses = [
|
const routingFormResponses = [
|
||||||
{
|
{
|
||||||
id: 1,
|
formFillerId: `${randomString()}`,
|
||||||
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
|
|
||||||
response: {
|
response: {
|
||||||
"participant-field": {
|
"participant-field": {
|
||||||
label: "participant",
|
label: "participant",
|
||||||
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
const responseData = responseBody.data;
|
const responseData = responseBody.data;
|
||||||
expect(responseData).toBeDefined();
|
expect(responseData).toBeDefined();
|
||||||
expect(responseData.length).toEqual(1);
|
expect(responseData.length).toEqual(1);
|
||||||
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
|
|
||||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||||
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||||
|
await routingFormsRepositoryFixture.delete(routingFormId);
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
+2
@@ -6,6 +6,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
|||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||||
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
|||||||
IsTeamInOrg,
|
IsTeamInOrg,
|
||||||
IsRoutingFormInTeam,
|
IsRoutingFormInTeam,
|
||||||
PlatformPlanGuard,
|
PlatformPlanGuard,
|
||||||
|
RolesGuard,
|
||||||
IsAdminAPIEnabledGuard
|
IsAdminAPIEnabledGuard
|
||||||
)
|
)
|
||||||
@ApiHeader(API_KEY_HEADER)
|
@ApiHeader(API_KEY_HEADER)
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||||
|
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||||
|
import {
|
||||||
|
TeamVerifiedEmailOutput,
|
||||||
|
TeamVerifiedEmailsOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedPhoneOutput,
|
||||||
|
TeamVerifiedPhonesOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { TOTP as TOTPtoMock } from "@otplib/core";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { totp } from "otplib";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
jest.spyOn(totp, "generate").mockImplementation(function () {
|
||||||
|
return "1234";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
|
||||||
|
.mockImplementation(async function () {
|
||||||
|
return {
|
||||||
|
to: `testnotrealemail@notreal.com`,
|
||||||
|
from: `testnotrealemail@notreal.com`,
|
||||||
|
subject: "No Subject",
|
||||||
|
html: "<html><body>Mocked Email Content</body></html>",
|
||||||
|
text: "body",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Organizations Teams Verified Resources", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
|
||||||
|
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
|
let org: Team;
|
||||||
|
let orgTeam: Team;
|
||||||
|
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
|
||||||
|
const phoneToVerify = "+37255556666";
|
||||||
|
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
|
||||||
|
let user: User;
|
||||||
|
let apiKeyString: string;
|
||||||
|
let verifiedEmailId: number;
|
||||||
|
let verifiedPhoneId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
org = await organizationsRepositoryFixture.create({
|
||||||
|
name: `organizations-verified-resources-responses-organization-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: authEmail,
|
||||||
|
username: authEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
|
|
||||||
|
orgTeam = await teamsRepositoryFixture.create({
|
||||||
|
name: `organizations-verified-resources-responses-team-${randomString()}`,
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: orgTeam.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${user.id}`,
|
||||||
|
username: authEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
verifiedPhoneId = (
|
||||||
|
await verifiedResourcesRepositoryFixtures.createPhone({
|
||||||
|
phoneNumber: phoneToVerify,
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: {
|
||||||
|
connect: {
|
||||||
|
id: orgTeam.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger email verification code", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(
|
||||||
|
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
|
||||||
|
)
|
||||||
|
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify email", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(
|
||||||
|
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
|
||||||
|
)
|
||||||
|
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailOutput;
|
||||||
|
verifiedEmailId = response.data.id;
|
||||||
|
expect(response.data.email).toEqual(emailToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified email by id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailOutput;
|
||||||
|
expect(response.data.email).toEqual(emailToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified number by id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedPhoneOutput;
|
||||||
|
expect(response.data.phoneNumber).toEqual(phoneToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified emails", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailsOutput;
|
||||||
|
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
|
||||||
|
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified phones", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedPhonesOutput;
|
||||||
|
expect(
|
||||||
|
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
|
||||||
|
).toBeDefined();
|
||||||
|
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
|
||||||
|
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
|
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||||
|
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
|
||||||
|
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||||
|
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
|
||||||
|
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
|
||||||
|
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedEmailOutput,
|
||||||
|
TeamVerifiedEmailOutputData,
|
||||||
|
TeamVerifiedEmailsOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedPhoneOutput,
|
||||||
|
TeamVerifiedPhoneOutputData,
|
||||||
|
TeamVerifiedPhonesOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||||
|
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
|
||||||
|
})
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiTags("Organization Team Verified Resources")
|
||||||
|
export class OrgTeamsVerifiedResourcesController {
|
||||||
|
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Request Email Verification Code",
|
||||||
|
description: `Sends a verification code to the email.`,
|
||||||
|
})
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_emails_requests",
|
||||||
|
})
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Post("/emails/verification-code/request")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async requestEmailVerificationCode(
|
||||||
|
@Body() body: RequestEmailVerificationInput,
|
||||||
|
@GetUser("username") username: string,
|
||||||
|
@GetUser("locale") locale: string
|
||||||
|
): Promise<RequestEmailVerificationOutput> {
|
||||||
|
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
|
||||||
|
{ username, locale },
|
||||||
|
body.email
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Request Phone Number Verification Code",
|
||||||
|
description: `Sends a verification code to the phone number.`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_phones_requests",
|
||||||
|
})
|
||||||
|
@Post("/phones/verification-code/request")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async requestPhoneVerificationCode(
|
||||||
|
@Body() body: RequestPhoneVerificationInput
|
||||||
|
): Promise<RequestPhoneVerificationOutput> {
|
||||||
|
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
|
||||||
|
body.phone
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Verify an email for an org team.",
|
||||||
|
description: `Use code to verify an email`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Post("/emails/verification-code/verify")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyEmail(
|
||||||
|
@Body() body: VerifyEmailInput,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedEmailOutput> {
|
||||||
|
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
|
||||||
|
userId,
|
||||||
|
body.email,
|
||||||
|
body.code,
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Verify a phone number for an org team.",
|
||||||
|
description: `Use code to verify a phone number`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Post("/phones/verification-code/verify")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_phones_verify",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyPhoneNumber(
|
||||||
|
@Body() body: VerifyPhoneInput,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedPhoneOutput> {
|
||||||
|
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
|
||||||
|
userId,
|
||||||
|
body.phone,
|
||||||
|
body.code,
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get list of verified emails of an org team.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/emails")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedEmails(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Query() pagination: SkipTakePagination
|
||||||
|
): Promise<TeamVerifiedEmailsOutput> {
|
||||||
|
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
|
||||||
|
teamId,
|
||||||
|
pagination?.skip ?? 0,
|
||||||
|
pagination?.take ?? 250
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get list of verified phone numbers of an org team.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/phones")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedPhoneNumbers(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Query() pagination: SkipTakePagination
|
||||||
|
): Promise<TeamVerifiedPhonesOutput> {
|
||||||
|
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
|
||||||
|
teamId,
|
||||||
|
pagination?.skip ?? 0,
|
||||||
|
pagination?.take ?? 250
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
|
||||||
|
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get verified email of an org team by id.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/emails/:id")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedEmailById(
|
||||||
|
@Param("id") id: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedEmailOutput> {
|
||||||
|
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get verified phone number of an org team by id.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/phones/:id")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedPhoneById(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("id") id: number
|
||||||
|
): Promise<TeamVerifiedPhoneOutput> {
|
||||||
|
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
|
||||||
|
teamId,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -40,7 +40,7 @@ export class OrganizationsUsersBookingsController {
|
|||||||
@Param("userId", ParseIntPipe) userId: number,
|
@Param("userId", ParseIntPipe) userId: number,
|
||||||
@Query() query: GetBookingsInput_2024_08_13
|
@Query() query: GetBookingsInput_2024_08_13
|
||||||
): Promise<GetBookingsOutput_2024_08_13> {
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
const { bookings, pagination } = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||||
orgId,
|
orgId,
|
||||||
userId,
|
userId,
|
||||||
query
|
query
|
||||||
@@ -49,6 +49,7 @@ export class OrganizationsUsersBookingsController {
|
|||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: bookings,
|
data: bookings,
|
||||||
|
pagination,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
@@ -29,6 +29,7 @@ import {
|
|||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
PaginationMetaDto,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -373,17 +374,26 @@ describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
|
|||||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.pagination).toBeDefined();
|
||||||
const data: (
|
const data: (
|
||||||
| BookingOutput_2024_08_13
|
| BookingOutput_2024_08_13
|
||||||
| RecurringBookingOutput_2024_08_13
|
| RecurringBookingOutput_2024_08_13
|
||||||
| GetSeatedBookingOutput_2024_08_13
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
)[] = responseBody.data;
|
)[] = responseBody.data;
|
||||||
|
const pagination: PaginationMetaDto = responseBody.pagination;
|
||||||
expect(data.length).toEqual(3);
|
expect(data.length).toEqual(3);
|
||||||
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
|
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
|
||||||
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
|
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
|
||||||
expect(
|
expect(
|
||||||
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
|
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
|
expect(pagination.totalItems).toEqual(3);
|
||||||
|
expect(pagination.remainingItems).toEqual(0);
|
||||||
|
expect(pagination.hasNextPage).toEqual(false);
|
||||||
|
expect(pagination.hasPreviousPage).toEqual(false);
|
||||||
|
expect(pagination.itemsPerPage).toEqual(100);
|
||||||
|
expect(pagination.totalPages).toEqual(1);
|
||||||
|
expect(pagination.currentPage).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-4
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
|
|||||||
member: {
|
member: {
|
||||||
teamId: orgId,
|
teamId: orgId,
|
||||||
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
||||||
// Filter to only get users which have ALL of the assigned attribue options
|
// Filter to only get users which have ALL of the assigned attribute options
|
||||||
...(attributeQueryOperator === "AND" && {
|
...(attributeQueryOperator === "AND" && {
|
||||||
AND: assignedOptionIds.map((optionId) => ({
|
AND: assignedOptionIds.map((optionId) => ({
|
||||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||||
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||||
// Filter to get users which have AT LEAST ONE of the assigned attribue options
|
// Filter to get users which have AT LEAST ONE of the assigned attribute options
|
||||||
...(attributeQueryOperator === "OR" && {
|
...(attributeQueryOperator === "OR" && {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
}),
|
}),
|
||||||
// Filter to get users that have NONE the assigned attribue options
|
// Filter to get users that have NONE the assigned attribute options
|
||||||
...(attributeQueryOperator === "NONE" && {
|
...(attributeQueryOperator === "NONE" && {
|
||||||
NOT: {
|
NOT: {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
|
|||||||
return await this.dbWrite.prisma.user.delete({
|
return await this.dbWrite.prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
organizationId: orgId,
|
OR: [
|
||||||
|
{ organizationId: orgId },
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
some: {
|
||||||
|
organizationId: orgId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
Loaded 100 of 923 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user