Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$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,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -159,13 +159,17 @@ SENDGRID_EMAIL=
|
||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||
|
||||
# Sentry
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
|
||||
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_SAMPLE_RATE=
|
||||
SENTRY_TRACES_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||
|
||||
# Formbricks Experience Management Integration
|
||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
@@ -198,6 +202,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||
STRIPE_TEAM_PRODUCT_ID=
|
||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
@@ -363,9 +368,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
|
||||
# api v2
|
||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||
|
||||
@@ -425,7 +427,7 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
IFFY_API_KEY=
|
||||
|
||||
## 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
|
||||
@@ -443,4 +445,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
NEXT_PUBLIC_HEAD_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
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer
|
||||
/apps/web/components/**/* @calcom/Consumer
|
||||
/apps/web/modules/**/* @calcom/Consumer
|
||||
/apps/web/**/layout.tsx @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
/deploy/**/* @calcom/Foundation
|
||||
@@ -26,5 +24,7 @@
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @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/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
|
||||
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:
|
||||
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
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@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
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"
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -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/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"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 { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||
import {
|
||||
getAccessibleUsers,
|
||||
retrieveOrgScopedAccessibleUsers,
|
||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
||||
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) {
|
||||
const {
|
||||
userId,
|
||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||
: [];
|
||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||
let userEmailsToFilterBy: string[] = [];
|
||||
|
||||
/** Only admins can query other users */
|
||||
if (isSystemWideAdmin) {
|
||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
||||
userId,
|
||||
};
|
||||
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) {
|
||||
let requestedUserIds = [userId];
|
||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
||||
userId,
|
||||
};
|
||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
||||
userEmailsToFilterBy = userEmails;
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||
} else {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
||||
if (!user) {
|
||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||
}
|
||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
||||
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||
}
|
||||
|
||||
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)) };
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
||||
currentCredentialId: number | null;
|
||||
}) {
|
||||
if (parsedBody.integration && parsedBody.externalId) {
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||
userCredentials.map((cred) => ({
|
||||
...cred,
|
||||
delegationCredentialId: null,
|
||||
}))
|
||||
);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
|
||||
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
|
||||
*/
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const data = membershipCreateBodySchema.parse(req.body);
|
||||
const args: Prisma.MembershipCreateArgs = { data };
|
||||
const args: Prisma.MembershipCreateArgs = {
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
},
|
||||
};
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
|
||||
console.log("bookings=>", responseData.bookings);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
||||
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"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"],
|
||||
"workerIdleMemoryLimit": "512MB",
|
||||
"maxWorkers": 8
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
@@ -38,7 +37,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@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-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
KyselyModule,
|
||||
RedisModule,
|
||||
TokensModule,
|
||||
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 { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly kyselyReadService: KyselyReadService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly config: ConfigService,
|
||||
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
|
||||
ctx: {
|
||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||
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 { 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 { 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 { 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 { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
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 { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
KyselyModule,
|
||||
RedisModule,
|
||||
TokensModule,
|
||||
BillingModule,
|
||||
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
ErrorsBookingsService_2024_08_13,
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
],
|
||||
controllers: [BookingsController_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 { 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 { 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 { 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 { 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 { 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 { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -176,7 +182,7 @@ export class BookingsController_2024_08_13 {
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, {
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
orgId: profile?.organizationId,
|
||||
@@ -185,6 +191,7 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,4 +393,30 @@ export class BookingsController_2024_08_13 {
|
||||
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 seatedEvent: EventType;
|
||||
let eventTypeWithBookingFields: EventType;
|
||||
let eventTypeWithBookingFielsCustom: EventType;
|
||||
const eventTypeSlug = `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()}`;
|
||||
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
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({
|
||||
user: {
|
||||
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 () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.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;
|
||||
} else {
|
||||
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;
|
||||
} else {
|
||||
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;
|
||||
} else {
|
||||
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;
|
||||
} else {
|
||||
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 { NestExpressApplication } from "@nestjs/platform-express";
|
||||
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 * as request from "supertest";
|
||||
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 { TeamRepositoryFixture } from "test/fixtures/repository/team.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 { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
|
||||
let workflowRepositoryFixture: WorkflowRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
let eventTypeWithAttendeeSmsReminder: EventType;
|
||||
let workflow: Workflow;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
|
||||
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-bookings-organization-${randomString()}`,
|
||||
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
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();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -583,7 +671,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||
} else {
|
||||
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);
|
||||
} else {
|
||||
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", () => {
|
||||
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 = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
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 {
|
||||
return (
|
||||
!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 { BillingService } from "@/modules/billing/services/billing.service";
|
||||
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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
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 { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -58,13 +60,17 @@ type CreatedBooking = {
|
||||
start: string;
|
||||
};
|
||||
|
||||
const eventTypeBookingFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
editable: z.string(),
|
||||
});
|
||||
const eventTypeBookingFieldSchema = z
|
||||
.object({
|
||||
name: 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 };
|
||||
|
||||
@@ -78,6 +84,7 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly kyselyReadService: KyselyReadService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@@ -169,36 +176,187 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
.parse(eventType.bookingFields)
|
||||
.filter((field) => !field.editable.startsWith("system"));
|
||||
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
|
||||
|
||||
if (!eventTypeBookingFields.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const field of eventTypeBookingFields) {
|
||||
if (field.required && !(field.name in bookingFields)) {
|
||||
if (field.name === "attendeePhoneNumber") {
|
||||
for (const eventTypeBookingField of eventTypeBookingFields) {
|
||||
if (
|
||||
eventTypeBookingField.required &&
|
||||
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
|
||||
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
|
||||
) {
|
||||
if (
|
||||
eventTypeBookingField.name === "attendeePhoneNumber" ||
|
||||
eventTypeBookingField.name === "smsReminderNumber"
|
||||
) {
|
||||
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(
|
||||
`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;
|
||||
}
|
||||
|
||||
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(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
@@ -374,7 +532,7 @@ export class BookingsService_2024_08_13 {
|
||||
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 || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
take: queryParams.take ?? 100,
|
||||
@@ -385,6 +543,7 @@ export class BookingsService_2024_08_13 {
|
||||
ctx: {
|
||||
user,
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
kysely: this.kyselyReadService.kysely,
|
||||
},
|
||||
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 }) {
|
||||
@@ -569,6 +750,20 @@ export class BookingsService_2024_08_13 {
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
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
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||
|
||||
const needsSmsReminderNumber = eventType.bookingFields
|
||||
? eventTypeBookingFieldsSchema
|
||||
.parse(eventType.bookingFields)
|
||||
.find((field) => field.name === "smsReminderNumber")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
name: inputBooking.attendee.name,
|
||||
email: attendeeEmail ?? "",
|
||||
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
||||
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
|
||||
guests,
|
||||
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,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
|
||||
@@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
|
||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
teamId: null,
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
|
||||
+70
-2
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
hideOrganizerEmail: false,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
eventType.bookingFields = updatedEventType.bookingFields;
|
||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1420,7 +1428,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
@@ -1550,7 +1557,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
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 () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.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 { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -19,7 +18,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
providers: [
|
||||
EventTypesRepository_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 { 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 {
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
@@ -50,8 +53,7 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
private readonly calendarsService: CalendarsService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
@@ -104,13 +106,6 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
lengthInMinutes,
|
||||
lengthInMinutesOptions,
|
||||
@@ -130,10 +125,11 @@ export class InputEventTypesService_2024_06_14 {
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: this.transformInputLocations(locations || defaultLocations),
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
@@ -228,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
||||
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
||||
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 systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
||||
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
||||
@@ -237,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||
systemCustomNameField || systemBeforeFieldName,
|
||||
systemCustomEmailField || systemBeforeFieldEmail,
|
||||
systemBeforeFieldLocation,
|
||||
systemCustomLocationField || systemBeforeFieldLocation,
|
||||
];
|
||||
|
||||
const defaultFieldsAfter = [
|
||||
@@ -278,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
field.name !== "title" &&
|
||||
field.name !== "notes" &&
|
||||
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") {
|
||||
// cal-video is global, so we can skip this check
|
||||
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"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
seatsShowAttendees,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
destinationCalendar,
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ if (process.env.SENTRY_DSN) {
|
||||
// Ensure to call this before requiring any other modules!
|
||||
Sentry.init({
|
||||
dsn: getEnv("SENTRY_DSN"),
|
||||
integrations: [nodeProfilingIntegration()],
|
||||
integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
||||
// 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 { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
||||
ttl: z.number(),
|
||||
blockDuration: z.number(),
|
||||
});
|
||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||
|
||||
const sixtySecondsMs = 60 * 1000;
|
||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||
const { context } = requestProps;
|
||||
|
||||
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
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}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.handleApiKeyRequest(tracker, response);
|
||||
} 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> {
|
||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||
|
||||
|
||||
@@ -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 { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
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 { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
|
||||
BillingRepository,
|
||||
BillingProcessor,
|
||||
ManagedOrganizationsBillingService,
|
||||
OAuthClientRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
],
|
||||
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
||||
controllers: [BillingController],
|
||||
|
||||
@@ -15,12 +15,21 @@ export class BillingRepository {
|
||||
},
|
||||
});
|
||||
|
||||
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
|
||||
return this.dbRead.prisma.platformBilling.findFirst({
|
||||
where: {
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamBilling(
|
||||
teamId: number,
|
||||
billingStart: number,
|
||||
billingEnd: number,
|
||||
plan: PlatformPlan,
|
||||
subscriptionId?: string
|
||||
subscriptionId?: string,
|
||||
priceId?: string
|
||||
) {
|
||||
return this.dbWrite.prisma.platformBilling.update({
|
||||
where: {
|
||||
@@ -32,6 +41,7 @@ export class BillingRepository {
|
||||
subscriptionId,
|
||||
plan: plan.toString(),
|
||||
overdue: false,
|
||||
priceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async stripeWebhook(
|
||||
@@ -113,6 +127,9 @@ export class BillingController {
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
@@ -29,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
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
|
||||
) {
|
||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
@@ -118,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
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 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) {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
|
||||
|
||||
if (checkoutSession.mode === "subscription") {
|
||||
await this.setSubscriptionForTeam(
|
||||
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
|
||||
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,
|
||||
checkoutSession.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
@@ -219,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
|
||||
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) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
@@ -262,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
|
||||
await this.setSubscriptionForTeam(
|
||||
await this.setPerBookingSubscriptionForTeam(
|
||||
teamId,
|
||||
teamWithBilling?.platformBilling?.subscriptionId,
|
||||
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() {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum PlatformPlan {
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
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 { StripeModule } from "@/modules/stripe/stripe.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 { Module } from "@nestjs/common";
|
||||
|
||||
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
OrganizationsRoutingFormsModule,
|
||||
VerifiedResourcesModule,
|
||||
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) {
|
||||
const membership = await this.dbRead.prisma.membership.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
role,
|
||||
teamId,
|
||||
userId,
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -45,7 +45,7 @@ export class OrganizationsBookingsController {
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const { userIds, ...restParams } = queryParams;
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||
{ ...restParams },
|
||||
{ email: user.email, id: user.id, orgId },
|
||||
userIds
|
||||
@@ -54,6 +54,7 @@ export class OrganizationsBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
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 { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
@@ -27,7 +28,8 @@ export class InputOrganizationsEventTypesService {
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly conferencingService: OrganizationsConferencingService
|
||||
private readonly conferencingService: OrganizationsConferencingService,
|
||||
private readonly conferencingRepository: ConferencingRepository
|
||||
) {}
|
||||
async transformAndValidateCreateTeamEventTypeInput(
|
||||
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 {
|
||||
|
||||
@@ -70,6 +70,7 @@ type Input = Pick<
|
||||
| "eventName"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "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 { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
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 { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { Attribute, AttributeOption, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Memberships Endpoints", () => {
|
||||
@@ -33,7 +33,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
@@ -49,6 +50,17 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
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 = {
|
||||
some: "key",
|
||||
};
|
||||
@@ -64,7 +76,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -91,16 +104,42 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
await setupAttributes();
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership2 = await membershipsRepositoryFixture.create({
|
||||
membership2 = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.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();
|
||||
@@ -109,6 +148,87 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
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", () => {
|
||||
expect(userRepositoryFixture).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.username).toEqual(user2.username);
|
||||
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 () => {
|
||||
return request(app.getHttpServer())
|
||||
.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 () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
@@ -259,7 +443,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
@@ -277,7 +462,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -289,7 +475,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.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 { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@@ -70,9 +68,7 @@ export class OrganizationsMembershipsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: memberships.map((membership) =>
|
||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
data: memberships,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +84,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
data: membership,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,7 +140,7 @@ export class OrganizationsMembershipsController {
|
||||
);
|
||||
return {
|
||||
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 { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
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()
|
||||
export class OrganizationsMembershipRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -16,7 +39,12 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +54,12 @@ export class OrganizationsMembershipRepository {
|
||||
teamId: organizationId,
|
||||
userId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +69,12 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,14 +83,24 @@ export class OrganizationsMembershipRepository {
|
||||
create: { ...data, teamId: organizationId },
|
||||
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
|
||||
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) {
|
||||
return this.dbWrite.prisma.membership.update({
|
||||
data: { ...data },
|
||||
where: { id: membershipId, teamId: organizationId },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +109,12 @@ export class OrganizationsMembershipRepository {
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
skip,
|
||||
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 { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
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 { IsEnum } from "class-validator";
|
||||
|
||||
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
|
||||
@IsEnum([SUCCESS_STATUS, 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 { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
|
||||
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamMembershipOutput)
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
@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 { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class GetOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
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 { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
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 { 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 { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsMembershipService {
|
||||
constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
|
||||
constructor(
|
||||
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
|
||||
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
|
||||
) {}
|
||||
|
||||
async getOrgMembership(organizationId: number, membershipId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||
organizationId,
|
||||
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) {
|
||||
@@ -21,7 +32,13 @@ export class OrganizationsMembershipService {
|
||||
organizationId,
|
||||
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) {
|
||||
@@ -30,7 +47,7 @@ export class OrganizationsMembershipService {
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return memberships;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships);
|
||||
}
|
||||
|
||||
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
||||
@@ -38,7 +55,7 @@ export class OrganizationsMembershipService {
|
||||
organizationId,
|
||||
membershipId
|
||||
);
|
||||
return membership;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||
@@ -47,11 +64,11 @@ export class OrganizationsMembershipService {
|
||||
membershipId,
|
||||
data
|
||||
);
|
||||
return membership;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
|
||||
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 { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
|
||||
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 { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
@@ -98,6 +99,7 @@ import { Module } from "@nestjs/common";
|
||||
EmailService,
|
||||
OrganizationsMembershipRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsEventTypesService,
|
||||
InputOrganizationsEventTypesService,
|
||||
OutputOrganizationsEventTypesService,
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.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 { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
ManagedOrganizationsBillingService,
|
||||
OrganizationsRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsMembershipRepository,
|
||||
ManagedOrganizationsOutputService,
|
||||
],
|
||||
|
||||
+40
-30
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
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`, () => {
|
||||
it("should not get routing form responses for non existing org", async () => {
|
||||
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 { 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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
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 { 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 { 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 { 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";
|
||||
|
||||
@Controller({
|
||||
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
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("/")
|
||||
@ApiOperation({ summary: "Get organization team bookings" })
|
||||
@@ -46,7 +54,7 @@ export class OrganizationsTeamsBookingsController {
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.bookingsService.getBookings(
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||
{ ...queryParams, teamId },
|
||||
{ email: user.email, id: user.id, orgId }
|
||||
);
|
||||
@@ -54,6 +62,36 @@ export class OrganizationsTeamsBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
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 { 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 { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
||||
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
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],
|
||||
})
|
||||
export class OrganizationsTeamsBookingsModule {}
|
||||
|
||||
+5
-1
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
|
||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.create({
|
||||
data: { ...data, teamId: teamId },
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let routingFormResponseId: string;
|
||||
let routingFormResponseId: number;
|
||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
let routingFormId: string;
|
||||
const routingFormResponses = [
|
||||
{
|
||||
id: 1,
|
||||
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
|
||||
formFillerId: `${randomString()}`,
|
||||
response: {
|
||||
"participant-field": {
|
||||
label: "participant",
|
||||
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
const responseData = responseBody.data;
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.length).toEqual(1);
|
||||
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
|
||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||
await routingFormsRepositoryFixture.delete(routingFormId);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
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 { 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 { 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 { 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,
|
||||
IsRoutingFormInTeam,
|
||||
PlatformPlanGuard,
|
||||
RolesGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@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,
|
||||
@Query() query: GetBookingsInput_2024_08_13
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
const { bookings, pagination } = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
orgId,
|
||||
userId,
|
||||
query
|
||||
@@ -49,6 +49,7 @@ export class OrganizationsUsersBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -29,6 +29,7 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
PaginationMetaDto,
|
||||
} from "@calcom/platform-types";
|
||||
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;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.pagination).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
const pagination: PaginationMetaDto = responseBody.pagination;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
|
||||
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
|
||||
expect(
|
||||
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
|
||||
).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: {
|
||||
teamId: orgId,
|
||||
...(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" && {
|
||||
AND: assignedOptionIds.map((optionId) => ({
|
||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
|
||||
}),
|
||||
},
|
||||
...(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" && {
|
||||
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" && {
|
||||
NOT: {
|
||||
attributeOption: { id: { in: assignedOptionIds } },
|
||||
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
|
||||
return await this.dbWrite.prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
organizationId: orgId,
|
||||
OR: [
|
||||
{ organizationId: orgId },
|
||||
{
|
||||
profiles: {
|
||||
some: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
|
||||
@@ -22,6 +23,7 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsDelegationCredentialRepository,
|
||||
OrganizationsMembershipRepository,
|
||||
SelectedCalendarsService,
|
||||
|
||||
+86
@@ -685,6 +685,92 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
describe("routingFormResponseId and _isDryRun validation", () => {
|
||||
const baseUrl = "/api/v2/slots/available";
|
||||
const baseParams = {
|
||||
startTime: "2050-09-05",
|
||||
endTime: "2050-09-10",
|
||||
};
|
||||
|
||||
it("should allow routingFormResponseId=0 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=1 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be 0 for dry run"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow routingFormResponseId=1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=0 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: -1,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
@@ -157,7 +157,11 @@ export class SlotsController_2024_04_15 {
|
||||
@Query() query: GetAvailableSlotsInput_2024_04_15,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
|
||||
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
|
||||
try {
|
||||
const isTeamEvent =
|
||||
query.isTeamEvent === undefined
|
||||
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
|
||||
: query.isTeamEvent;
|
||||
const availableSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...query,
|
||||
@@ -168,19 +172,29 @@ export class SlotsController_2024_04_15 {
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid time range given")) {
|
||||
throw new BadRequestException(
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class SlotsService_2024_04_15 {
|
||||
|
||||
let shouldReserveSlot = true;
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid);
|
||||
const bookingWithAttendees = input.bookingUid
|
||||
? await this.slotsRepo.getBookingWithAttendees(input.bookingUid)
|
||||
: undefined;
|
||||
const bookingAttendeesLength = bookingWithAttendees?.attendees?.length;
|
||||
if (bookingAttendeesLength) {
|
||||
const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user