Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6f4e51b859 | ||
|
|
4f5d0db792 | ||
|
|
8733bcaf7a | ||
|
|
5aaca2b7aa | ||
|
|
eacd05bf3b | ||
|
|
d51e0182b5 | ||
|
|
220897ba86 | ||
|
|
78c0203a45 | ||
|
|
720fd90066 | ||
|
|
ca8ee749a2 | ||
|
|
ce2d3a4b00 | ||
|
|
2923684c7c | ||
|
|
879ae597be | ||
|
|
d21f7ccbc8 | ||
|
|
4cc1bc6746 | ||
|
|
49a2c60212 | ||
|
|
7dfbb42ced | ||
|
|
41fafb9e5e | ||
|
|
6815f2f9c7 | ||
|
|
91ab27e070 | ||
|
|
396ccb1cd2 | ||
|
|
4e8d5cebca | ||
|
|
80f2e61189 | ||
|
|
d844fd6ef7 | ||
|
|
513f54f4fe | ||
|
|
2e6d71f493 | ||
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 | ||
|
|
e1beec0487 | ||
|
|
6bbf263981 | ||
|
|
d86a28bf7f | ||
|
|
c00e8cfa54 | ||
|
|
e82cc328d3 | ||
|
|
7dbfc1b2e0 | ||
|
|
02fb7c0ae0 | ||
|
|
9004dcb93b | ||
|
|
cf83b0a236 | ||
|
|
7f02464fed | ||
|
|
2371253cb5 | ||
|
|
859e7f1c88 | ||
|
|
f1e8481878 | ||
|
|
acf3a7c54e | ||
|
|
f163e4638b | ||
|
|
9dab915ddd | ||
|
|
8fca8f3910 | ||
|
|
1638465607 | ||
|
|
ab7eb7f4b1 | ||
|
|
0592badd56 | ||
|
|
7e82b8fa86 | ||
|
|
7dfb71ca30 | ||
|
|
d99ecde0e2 | ||
|
|
d81b905ad7 | ||
|
|
46c6582328 | ||
|
|
396811b954 | ||
|
|
f6c7d8d97f | ||
|
|
930165b6e8 | ||
|
|
a77bd7bbe0 | ||
|
|
51212cdacf | ||
|
|
3731b1ef3d | ||
|
|
f631c4aa79 | ||
|
|
95cd76e732 | ||
|
|
7e525779b7 | ||
|
|
f176ebf1da | ||
|
|
5058e0e750 | ||
|
|
dc409e1c5a | ||
|
|
89b5d1ebc3 | ||
|
|
0d24664a72 | ||
|
|
69efc26f36 |
+6
-1
@@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
|||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
TWILIO_OPT_OUT_ENABLED=
|
||||||
|
|
||||||
# Set it to "1" if you need to run E2E tests locally.
|
# Set it to "1" if you need to run E2E tests locally.
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
@@ -442,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
|||||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||||
|
|
||||||
DATABASE_CHUNK_SIZE=
|
DATABASE_CHUNK_SIZE=
|
||||||
|
|
||||||
|
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||||
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
|
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
|
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||||
|
/apps/web/modules/**/* @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
|
/deploy/**/* @calcom/Foundation
|
||||||
/infra/**/* @calcom/Foundation
|
/infra/**/* @calcom/Foundation
|
||||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||||
@@ -20,4 +26,7 @@
|
|||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -219,6 +219,17 @@ jobs:
|
|||||||
uses: ./.github/workflows/publish-report.yml
|
uses: ./.github/workflows/publish-report.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
cleanup-report:
|
||||||
|
name: Cleanup HTML report
|
||||||
|
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
needs: [changes, check-label, publish-report]
|
||||||
|
uses: ./.github/workflows/cleanup-report.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
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
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@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
+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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma, Booking } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
import {
|
import {
|
||||||
getAccessibleUsers,
|
getAccessibleUsers,
|
||||||
retrieveOrgScopedAccessibleUsers,
|
retrieveOrgScopedAccessibleUsers,
|
||||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
|
||||||
*
|
|
||||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
|
||||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
|
||||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
|
||||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
|
||||||
*
|
|
||||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
userId: number | null,
|
|
||||||
attendeeEmails: string[],
|
|
||||||
userIds: number[] = [],
|
|
||||||
userEmails: string[] = []
|
|
||||||
) {
|
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
|
||||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
|
||||||
|
|
||||||
let whereClause = {};
|
|
||||||
|
|
||||||
if (filterByAttendeeEmails) {
|
|
||||||
whereClause = {
|
|
||||||
AND: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: attendeeEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
whereClause = {
|
|
||||||
OR: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: userEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handler(req: NextApiRequest) {
|
export async function handler(req: NextApiRequest) {
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||||
: [];
|
: [];
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
let userEmailsToFilterBy: string[] = [];
|
||||||
|
|
||||||
/** Only admins can query other users */
|
/** Only admins can query other users */
|
||||||
if (isSystemWideAdmin) {
|
if (isSystemWideAdmin) {
|
||||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
}
|
}
|
||||||
} else if (isOrganizationOwnerOrAdmin) {
|
} else if (isOrganizationOwnerOrAdmin) {
|
||||||
let requestedUserIds = [userId];
|
let requestedUserIds = [userId];
|
||||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
} else {
|
} else {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
let data: Booking[] = [];
|
||||||
|
|
||||||
|
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
|
||||||
|
const queryOne = prisma.booking.findMany(args);
|
||||||
|
|
||||||
|
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: userEmailsToFilterBy },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: dateFrom };
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauseForQueryTwo.endTime = { lte: dateTo };
|
||||||
|
}
|
||||||
|
if (status === "upcoming") {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
|
||||||
|
...args,
|
||||||
|
where: whereClauseForQueryTwo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
|
||||||
|
|
||||||
|
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
|
||||||
|
|
||||||
|
const bookingMap = new Map();
|
||||||
|
[...resultOne, ...resultTwo].forEach((booking) => {
|
||||||
|
bookingMap.set(booking.id, booking);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupedResults = Array.from(bookingMap.values());
|
||||||
|
|
||||||
|
if (args.orderBy) {
|
||||||
|
let sortField: keyof Booking;
|
||||||
|
let sortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
|
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
|
||||||
|
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
|
||||||
|
sortField = orderByKey as keyof Booking;
|
||||||
|
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
|
||||||
|
} else {
|
||||||
|
sortField = "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = sortDirection === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
dedupedResults.sort((a, b) => {
|
||||||
|
const aValue = a[sortField];
|
||||||
|
const bValue = b[sortField];
|
||||||
|
|
||||||
|
if (aValue < bValue) return -1 * sortOrder;
|
||||||
|
if (aValue > bValue) return 1 * sortOrder;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.take !== undefined && args.skip !== undefined) {
|
||||||
|
data = dedupedResults.slice(args.skip, args.skip + args.take);
|
||||||
|
} else {
|
||||||
|
data = dedupedResults;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await prisma.booking.findMany(args);
|
||||||
|
}
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
|||||||
currentCredentialId: number | null;
|
currentCredentialId: number | null;
|
||||||
}) {
|
}) {
|
||||||
if (parsedBody.integration && parsedBody.externalId) {
|
if (parsedBody.integration && parsedBody.externalId) {
|
||||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||||
|
userCredentials.map((cred) => ({
|
||||||
|
...cred,
|
||||||
|
delegationCredentialId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const { connectedCalendars } = await getConnectedCalendars(
|
const { connectedCalendars } = await getConnectedCalendars(
|
||||||
calendarCredentials,
|
calendarCredentials,
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
|
|||||||
*/
|
*/
|
||||||
async function postHandler(req: NextApiRequest) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const data = membershipCreateBodySchema.parse(req.body);
|
const data = membershipCreateBodySchema.parse(req.body);
|
||||||
const args: Prisma.MembershipCreateArgs = { data };
|
const args: Prisma.MembershipCreateArgs = {
|
||||||
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
|
|||||||
console.log("bookings=>", responseData.bookings);
|
console.log("bookings=>", responseData.bookings);
|
||||||
responseData.bookings.forEach((booking) => {
|
responseData.bookings.forEach((booking) => {
|
||||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
if (booking.id === 19) {
|
||||||
|
// The team field can be either null or undefined due to nullish() in the schema
|
||||||
|
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Date filtering", () => {
|
||||||
|
it("filters bookings by dateFrom", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "2023-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
|
||||||
|
new Date("2023-01-01T00:00:00Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters bookings by dateTo", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "2024-12-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
|
||||||
|
new Date("2024-12-31T23:59:59Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sorting and ordering", () => {
|
||||||
|
it("sorts bookings by createdAt in descending order", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
|
||||||
|
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple attendee email filtering", () => {
|
||||||
|
it("filters bookings by multiple attendee emails", async () => {
|
||||||
|
const attendeeEmails = ["test1@example.com", "test2@example.com"];
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: attendeeEmails,
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
|
||||||
|
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error cases", () => {
|
||||||
|
it("throws error for invalid status parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Result merging", () => {
|
||||||
|
it("does not return duplicate bookings when merging results from multiple queries", async () => {
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
|
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
|
|
||||||
|
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||||
|
where: { userId: testUser.id },
|
||||||
|
include: { attendees: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 100,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
|
||||||
|
const bookingIds = responseData.bookings.map((booking) => booking.id);
|
||||||
|
|
||||||
|
const uniqueBookingIds = new Set(bookingIds);
|
||||||
|
|
||||||
|
expect(uniqueBookingIds.size).toBe(bookingIds.length);
|
||||||
|
|
||||||
|
if (uniqueBookingIds.size !== bookingIds.length) {
|
||||||
|
const counts = bookingIds.reduce((acc, id) => {
|
||||||
|
acc[id] = (acc[id] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
const duplicates = Object.entries(counts)
|
||||||
|
.filter(([_, count]) => count > 1)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
|
|
||||||
|
describe("buildWhereClause", () => {
|
||||||
|
it("should return empty object when no filters are provided", () => {
|
||||||
|
const result = buildWhereClause(null, [], []);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userId when only userId is provided", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const result = buildWhereClause(userId, [], []);
|
||||||
|
expect(result).toEqual({ userId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userIds when userIds array is provided", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const result = buildWhereClause(null, [], userIds);
|
||||||
|
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by attendee emails when provided", () => {
|
||||||
|
const attendeeEmails = ["test@example.com", "user@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com", "user@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userId and attendee email filters", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(userId, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: 123 },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userIds and attendee email filters", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, userIds);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: { in: [1, 2, 3] } },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,6 +107,14 @@ $ yarn test:e2e:watch --testPathPattern=filePath
|
|||||||
$ yarn run test:cov
|
$ yarn run test:cov
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Guards
|
||||||
|
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||||
|
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||||
|
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||||
|
all auth methods are allowed.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
"reporters": ["default", "jest-summarizing-reporter"],
|
||||||
"workerIdleMemoryLimit": "512MB",
|
"workerIdleMemoryLimit": "512MB",
|
||||||
"maxWorkers": 8
|
"maxWorkers": 8
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.173",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.196",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
|||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly oAuthFlowService: OAuthFlowService,
|
private readonly oAuthFlowService: OAuthFlowService,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferencesRepository_2024_08_13 {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
|
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
||||||
|
|
||||||
|
if (filter?.type) {
|
||||||
|
whereClause.type = filter.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dbRead.prisma.bookingReference.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
uid: true,
|
||||||
|
id: true,
|
||||||
|
externalCalendarId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
@@ -16,6 +20,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
|||||||
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -35,6 +40,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -55,6 +61,7 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingsService_2024_08_13,
|
BookingsService_2024_08_13,
|
||||||
InputBookingsService_2024_08_13,
|
InputBookingsService_2024_08_13,
|
||||||
OutputBookingsService_2024_08_13,
|
OutputBookingsService_2024_08_13,
|
||||||
|
OutputBookingReferencesService_2024_08_13,
|
||||||
BookingsRepository_2024_08_13,
|
BookingsRepository_2024_08_13,
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
BookingSeatRepository,
|
BookingSeatRepository,
|
||||||
@@ -67,6 +74,9 @@ import { Module } from "@nestjs/common";
|
|||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
OrganizationsTeamsRepository,
|
OrganizationsTeamsRepository,
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
|
ErrorsBookingsService_2024_08_13,
|
||||||
|
BookingReferencesService_2024_08_13,
|
||||||
|
BookingReferencesRepository_2024_08_13,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_08_13],
|
controllers: [BookingsController_2024_08_13],
|
||||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||||
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsService: BookingsService_2024_08_13,
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: UsersService,
|
||||||
|
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -386,4 +392,30 @@ export class BookingsController_2024_08_13 {
|
|||||||
data: calendarLinks,
|
data: calendarLinks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid/references")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get 'Booking References' for a booking",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getBookingReferences(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||||
|
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||||
|
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
|
||||||
|
bookingUid,
|
||||||
|
userId,
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookingReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-627
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
let seatedEventTypeId: number;
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
const maxRecurrenceCount = 3;
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||||
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
);
|
);
|
||||||
seatedEventTypeId = seatedEvent.id;
|
seatedEventTypeId = seatedEvent.id;
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
seatsPerTimeSlot: 5,
|
|
||||||
seatsShowAttendees: true,
|
|
||||||
seatsShowAvailabilityCount: true,
|
|
||||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -321,127 +302,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should book a recurring event type with seats", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: "Mr Proper",
|
|
||||||
email: "mr_proper@gmail.com",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
metadata: {
|
|
||||||
userId: "300",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a booking for an event type with seats", async () => {
|
it("should get a booking for an event type with seats", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
||||||
@@ -469,62 +329,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of expected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking[0]);
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete expected.seatUid;
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get all seated bookings", async () => {
|
it("should get all seated bookings", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?sortCreated=asc")
|
.get("/v2/bookings?sortCreated=asc")
|
||||||
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.length).toEqual(3);
|
expect(responseBody.data.length).toEqual(1);
|
||||||
|
|
||||||
const seatedBooking = responseBody.data[0];
|
const seatedBooking = responseBody.data[0];
|
||||||
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
||||||
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete seatedBookingExpected.seatUid;
|
delete seatedBookingExpected.seatUid;
|
||||||
expect(seatedBooking).toEqual(seatedBookingExpected);
|
expect(seatedBooking).toEqual(seatedBookingExpected);
|
||||||
|
|
||||||
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
|
|
||||||
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of recurringSeatedBookingsExpected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -682,424 +476,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring seated bookings creation", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let organization: Team;
|
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
|
||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let oAuthClient: PlatformOAuthClient;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
|
|
||||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
|
||||||
let user: User;
|
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
|
||||||
const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await withApiAuth(
|
|
||||||
userEmail,
|
|
||||||
Test.createTestingModule({
|
|
||||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.overrideGuard(PermissionsGuard)
|
|
||||||
.useValue({
|
|
||||||
canActivate: () => true,
|
|
||||||
})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({
|
|
||||||
name: `seated-bookings-organization-${randomString()}`,
|
|
||||||
});
|
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
|
||||||
email: userEmail,
|
|
||||||
platformOAuthClients: {
|
|
||||||
connect: {
|
|
||||||
id: oAuthClient.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
|
||||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
isDefault: true,
|
|
||||||
};
|
|
||||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
|
||||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-event-type-${randomString()}`,
|
|
||||||
slug: seatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
seatedEventTypeId = seatedEvent.id;
|
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
|
||||||
bootstrap(app as NestExpressApplication);
|
|
||||||
|
|
||||||
await app.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
|
||||||
const data = {
|
|
||||||
logo: "logo-url",
|
|
||||||
name: "name",
|
|
||||||
redirectUris: ["http://localhost:5555"],
|
|
||||||
permissions: 32,
|
|
||||||
};
|
|
||||||
const secret = "secret";
|
|
||||||
|
|
||||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the first time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "1000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the second time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "2000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(2);
|
|
||||||
const firstAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeOne
|
|
||||||
);
|
|
||||||
expect(firstAttendee).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[0].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
const secondAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeTwo
|
|
||||||
);
|
|
||||||
expect(secondAttendee).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(2);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[1].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees[1]).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function responseDataIsCreateRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
|
||||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -642,7 +642,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
|
|||||||
const bookingUid = request.params.bookingUid;
|
const bookingUid = request.params.bookingUid;
|
||||||
|
|
||||||
if (!bookingUid) {
|
if (!bookingUid) {
|
||||||
throw new BadRequestException("Booking UID missing in the request path");
|
throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
@@ -15,7 +17,7 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve
|
|||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -77,6 +79,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
@@ -85,34 +88,19 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||||
private readonly organizationsRepository: OrganizationsRepository,
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
private readonly teamsRepository: TeamsRepository,
|
private readonly teamsRepository: TeamsRepository,
|
||||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
|
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBooking(request: Request, body: CreateBookingInput) {
|
async createBooking(request: Request, body: CreateBookingInput) {
|
||||||
|
let bookingTeamEventType = false;
|
||||||
try {
|
try {
|
||||||
const eventType = await this.getBookedEventType(body);
|
const eventType = await this.getBookedEventType(body);
|
||||||
|
if (eventType?.team) {
|
||||||
|
bookingTeamEventType = true;
|
||||||
|
}
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||||
throw new NotFoundException(
|
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.eventTypeId = eventType.id;
|
body.eventTypeId = eventType.id;
|
||||||
@@ -138,12 +126,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
return await this.createRegularBooking(request, body, eventType);
|
return await this.createRegularBooking(request, body, eventType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
|
||||||
if (error.message === "no_available_users_found_error") {
|
|
||||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +206,12 @@ export class BookingsService_2024_08_13 {
|
|||||||
body: CreateInstantBookingInput_2024_08_13,
|
body: CreateInstantBookingInput_2024_08_13,
|
||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
|
if (!eventType.team?.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Instant bookings are only supported for team event types, not individual user event types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleInstantMeeting(bookingRequest);
|
const booking = await handleInstantMeeting(bookingRequest);
|
||||||
|
|
||||||
@@ -313,29 +302,37 @@ export class BookingsService_2024_08_13 {
|
|||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleNewBooking({
|
try {
|
||||||
bookingData: bookingRequest.body,
|
const booking = await handleNewBooking({
|
||||||
userId: bookingRequest.userId,
|
bookingData: bookingRequest.body,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
});
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
throw new Error("Booking missing uid");
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "booking_seats_full_error") {
|
||||||
|
throw new ConflictException("No more seats left at this seated booking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
|
||||||
booking.uid
|
|
||||||
);
|
|
||||||
if (!databaseBooking) {
|
|
||||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBooking(uid: string) {
|
async getBooking(uid: string) {
|
||||||
@@ -390,6 +387,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user,
|
user,
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
sort: this.inputService.transformGetBookingsSort(queryParams),
|
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||||
});
|
});
|
||||||
@@ -697,17 +695,28 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(user);
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
const reassigned = await roundRobinManualReassignment({
|
try {
|
||||||
bookingId: booking.id,
|
const reassigned = await roundRobinManualReassignment({
|
||||||
newUserId,
|
bookingId: booking.id,
|
||||||
orgId: profile?.organizationId || null,
|
newUserId,
|
||||||
reassignReason: body.reason,
|
orgId: profile?.organizationId || null,
|
||||||
reassignedById,
|
reassignReason: body.reason,
|
||||||
emailsEnabled,
|
reassignedById,
|
||||||
platformClientParams,
|
emailsEnabled,
|
||||||
});
|
platformClientParams,
|
||||||
|
});
|
||||||
|
|
||||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "invalid_round_robin_host") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`User with id=${newUserId} is not a valid Round Robin host - the user to which you reassign this booking must be one of the booking hosts. Fetch the booking using following endpoint and select id of one of the hosts: https://cal.com/docs/api-reference/v2/bookings/get-a-booking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CreateBookingInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ErrorsBookingsService_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
|
||||||
|
|
||||||
|
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
|
||||||
|
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
|
||||||
|
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
if (bookingTeamEventType) {
|
||||||
|
throw new BadRequestException(hostsUnavaile);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||||
|
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The event type can't be booked at the "start" time provided. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||||
|
);
|
||||||
|
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||||
|
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||||
|
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||||
|
throw new BadRequestException(hostsUnavaile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
interface IBookingReference {
|
||||||
|
type: string;
|
||||||
|
uid: string;
|
||||||
|
id: number;
|
||||||
|
externalCalendarId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutputBookingReferencesService_2024_08_13 {
|
||||||
|
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
|
||||||
|
return bookingReferences.map((bookingReference) => ({
|
||||||
|
type: bookingReference.type,
|
||||||
|
eventUid: bookingReference.uid,
|
||||||
|
id: bookingReference.id,
|
||||||
|
destinationCalendarId: bookingReference?.externalCalendarId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
SeatedAttendee,
|
SeatedAttendee,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export const bookingResponsesSchema = z
|
export const bookingResponsesSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
|||||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
@@ -51,7 +52,7 @@ import {
|
|||||||
APPLE_CALENDAR,
|
APPLE_CALENDAR,
|
||||||
CREDENTIAL_CALENDARS,
|
CREDENTIAL_CALENDARS,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/v2/calendars",
|
path: "/v2/calendars",
|
||||||
@@ -135,6 +136,7 @@ export class CalendarsController {
|
|||||||
name: "calendar",
|
name: "calendar",
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Get("/:calendar/connect")
|
@Get("/:calendar/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -150,7 +152,7 @@ export class CalendarsController {
|
|||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Query("redir") redir?: string | null,
|
@Query("redir") redir?: string | null,
|
||||||
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
|
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
|
||||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
@@ -173,7 +175,7 @@ export class CalendarsController {
|
|||||||
@Get("/:calendar/save")
|
@Get("/:calendar/save")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
|
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
|
||||||
async save(
|
async save(
|
||||||
@Query("state") state: string,
|
@Query("state") state: string,
|
||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
@@ -221,11 +223,11 @@ export class CalendarsController {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Post("/:calendar/credentials")
|
@Post("/:calendar/credentials")
|
||||||
@ApiOperation({ summary: "Sync credentials" })
|
@ApiOperation({ summary: "Save Apple calendar credentials" })
|
||||||
async syncCredentials(
|
async syncCredentials(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Body() body: { username: string; password: string }
|
@Body() body: CreateCalendarCredentialsInput
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||||
if (username.length <= 1 || password.length <= 1)
|
if (!username || !password || username.length <= 1 || password.length <= 1) {
|
||||||
throw new BadRequestException(`Username or password cannot be empty`);
|
throw new BadRequestException(`Username or password cannot be empty`);
|
||||||
|
}
|
||||||
|
|
||||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||||
APPLE_CALENDAR_TYPE,
|
APPLE_CALENDAR_TYPE,
|
||||||
@@ -118,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: APPLE_CALENDAR_ID,
|
appId: APPLE_CALENDAR_ID,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dav = new CalendarService({
|
const dav = new CalendarService({
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: ICS_CALENDAR,
|
appId: ICS_CALENDAR,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+8
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
hideCalendarNotes: false,
|
hideCalendarNotes: false,
|
||||||
hideCalendarEventDetails: false,
|
hideCalendarEventDetails: false,
|
||||||
|
hideOrganizerEmail: false,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
|||||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||||
|
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||||
eventType.color = updatedEventType.color;
|
eventType.color = updatedEventType.color;
|
||||||
eventType.bookingFields = updatedEventType.bookingFields;
|
eventType.bookingFields = updatedEventType.bookingFields;
|
||||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
|
|||||||
+3
@@ -87,6 +87,7 @@ type Input = Pick<
|
|||||||
| "destinationCalendar"
|
| "destinationCalendar"
|
||||||
| "useEventTypeDestinationCalendarEmail"
|
| "useEventTypeDestinationCalendarEmail"
|
||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
|
| "hideOrganizerEmail"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
seatsShowAttendees,
|
seatsShowAttendees,
|
||||||
useEventTypeDestinationCalendarEmail,
|
useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -48,8 +46,6 @@ export class GcalController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly credentialRepository: CredentialsRepository,
|
private readonly credentialRepository: CredentialsRepository,
|
||||||
private readonly tokensRepository: TokensRepository,
|
|
||||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly gcalService: GCalService,
|
private readonly gcalService: GCalService,
|
||||||
private readonly calendarsService: CalendarsService
|
private readonly calendarsService: CalendarsService
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { MeController } from "@/ee/me/me.controller";
|
import { MeController } from "@/ee/me/me.controller";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
|
||||||
controllers: [MeController],
|
controllers: [MeController],
|
||||||
})
|
})
|
||||||
export class MeModule {}
|
export class MeModule {}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
|
|||||||
const value = process.env[key] as Environment[K] | undefined;
|
const value = process.env[key] as Environment[K] | undefined;
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
// handle fallback falsy cases that should still be used as value
|
||||||
|
if (fallback === false || fallback === "" || fallback === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { RateLimitType } from "@/lib/throttler-guard";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export const Throttle = Reflector.createDecorator<RateLimitType>();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getEnv } from "@/env";
|
import { getEnv } from "@/env";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
|||||||
ttl: z.number(),
|
ttl: z.number(),
|
||||||
blockDuration: z.number(),
|
blockDuration: z.number(),
|
||||||
});
|
});
|
||||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||||
|
|
||||||
const sixtySecondsMs = 60 * 1000;
|
const sixtySecondsMs = 60 * 1000;
|
||||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||||
const { context } = requestProps;
|
const { context } = requestProps;
|
||||||
|
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (throttleOptions) {
|
||||||
|
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||||
|
}
|
||||||
|
|
||||||
if (tracker.startsWith("api_key_")) {
|
if (tracker.startsWith("api_key_")) {
|
||||||
return this.handleApiKeyRequest(tracker, response);
|
return this.handleApiKeyRequest(tracker, response);
|
||||||
} else {
|
} else {
|
||||||
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
|
||||||
|
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
|
||||||
|
if (isBlocked) {
|
||||||
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||||
|
|
||||||
@@ -82,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allLimitsBlocked) {
|
if (allLimitsBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -96,7 +110,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -150,7 +164,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!apiKeyRecord) {
|
if (!apiKeyRecord) {
|
||||||
throw new UnauthorizedException("Invalid API Key");
|
throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
|
||||||
}
|
}
|
||||||
|
|
||||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
|
|||||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||||
|
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||||
|
eventType.teamMembers = onlyManagedTeamMembers;
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
||||||
|
|
||||||
|
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import { ExecutionContext, Inject } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
|
||||||
|
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||||
|
|
||||||
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
||||||
constructor() {
|
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRequest(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
|
||||||
|
request.allowedAuthMethods = allowedMethods;
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
|
|||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
|
|
||||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
|
||||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||||
return createMock<ExecutionContext>({
|
return createMock<ExecutionContext>({
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -16,65 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const teamId = request.params.teamId as string;
|
|
||||||
const orgId = request.params.orgId as string;
|
|
||||||
const user = request.user as ApiAuthGuardUser;
|
|
||||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
if (!minimumPlan) {
|
||||||
return canAccess;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const orgId = request.params.orgId;
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
|
||||||
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPlatformPlanAccess({
|
async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
|
||||||
teamId,
|
const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
|
||||||
orgId,
|
const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
user,
|
if (cachedValue !== null) {
|
||||||
minimumPlan,
|
return cachedValue === "true";
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
orgId?: string;
|
|
||||||
user: ApiAuthGuardUser;
|
|
||||||
minimumPlan: PlatformPlanType;
|
|
||||||
}): Promise<{ canAccess: boolean }> {
|
|
||||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
|
||||||
teamId ?? "none"
|
|
||||||
}:guard:platformbilling:${minimumPlan}`;
|
|
||||||
|
|
||||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
|
||||||
|
|
||||||
if (cachedAccess) {
|
|
||||||
return { canAccess: cachedAccess };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let canAccess = false;
|
const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||||
|
const isPlatform = organization?.isPlatform;
|
||||||
|
const hasSubscription = organization?.platformBilling?.subscriptionId;
|
||||||
|
|
||||||
if (user && orgId) {
|
if (!organization) {
|
||||||
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
|
||||||
const isPlatform = team?.isPlatform;
|
}
|
||||||
const hasSubscription = team?.platformBilling?.subscriptionId;
|
if (!isPlatform) {
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (!team) {
|
return true;
|
||||||
canAccess = false;
|
}
|
||||||
} else if (!isPlatform) {
|
if (!hasSubscription) {
|
||||||
canAccess = true;
|
throw new ForbiddenException(
|
||||||
} else if (!hasSubscription) {
|
`PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
|
||||||
canAccess = false;
|
);
|
||||||
} else {
|
}
|
||||||
canAccess = hasMinimumPlan({
|
if (
|
||||||
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
!hasMinimumPlan({
|
||||||
minimumPlan: minimumPlan,
|
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
|
||||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
minimumPlan: minimumPlan,
|
||||||
});
|
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||||
}
|
})
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
|
||||||
|
organization.platformBilling?.plan || "undefined"
|
||||||
|
}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (canAccess) {
|
return true;
|
||||||
return { canAccess };
|
|
||||||
}
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
|||||||
|
|
||||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
const orgId: string = request.params.orgId;
|
const orgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!membershipId) {
|
if (!membershipId) {
|
||||||
throw new ForbiddenException("No membership id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||||
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new NotFoundException(`Membership (${membershipId}) not found.`);
|
throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.membership = membership;
|
request.membership = membership;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
|
|||||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||||
handleRequest(error: Error, user: any) {
|
handleRequest(error: Error, user: any) {
|
||||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||||
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
|
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||||
if (user || noAuthProvided || !error) {
|
if (user || noAuthProvided || !error) {
|
||||||
return user || null;
|
return user || null;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,14 +22,19 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||||
if (organization) {
|
if (organization) {
|
||||||
request.organization = organization;
|
request.organization = organization;
|
||||||
}
|
}
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAdminAPIEnabled(
|
async checkAdminAPIEnabled(
|
||||||
@@ -57,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
if (!adminAPIAccessIsEnabledInOrg) {
|
if (!adminAPIAccessIsEnabledInOrg) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
`IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canAccess = true;
|
canAccess = true;
|
||||||
org &&
|
|
||||||
(await this.redisService.redis.set(
|
if (org && canAccess) {
|
||||||
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
"EX",
|
"EX",
|
||||||
300
|
300
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { canAccess, organization: org };
|
return { canAccess, organization: org };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-11
@@ -1,11 +1,5 @@
|
|||||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||||
import {
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
@@ -20,11 +14,11 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
|||||||
const managerOrgId: string = request.params.orgId;
|
const managerOrgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!managerOrgId) {
|
if (!managerOrgId) {
|
||||||
throw new ForbiddenException("No manager org id found in request params.");
|
throw new ForbiddenException("IsManagedOrgInManagerOrg - No manager org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!managedOrgId) {
|
if (!managedOrgId) {
|
||||||
throw new ForbiddenException("No managed org id found in request params.");
|
throw new ForbiddenException("IsManagedOrgInManagerOrg - No managed org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
|
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
|
||||||
@@ -33,8 +27,8 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!managedOrganization) {
|
if (!managedOrganization) {
|
||||||
throw new NotFoundException(
|
throw new ForbiddenException(
|
||||||
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.`
|
`IsManagedOrgInManagerOrg - Managed organization with id=${managedOrgId} is not owned by manager organization with id=${managerOrgId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsOrgGuard - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||||
@@ -31,7 +31,13 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
request.organization = org;
|
request.organization = org;
|
||||||
}
|
}
|
||||||
|
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsOrgGuard - provided organization id=${organizationId} does not represent any existing organization.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||||
@@ -55,7 +61,7 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
canAccess = true;
|
canAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org) {
|
if (org && canAccess) {
|
||||||
await this.redisService.redis.set(
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsWebhookInOrg - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
if (!webhookId) {
|
if (!webhookId) {
|
||||||
throw new ForbiddenException("No webhook id found in request params.");
|
throw new ForbiddenException("IsWebhookInOrg - No webhook id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
||||||
@@ -53,7 +53,7 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
if (isWebhookInOrg) canAccess = true;
|
if (isWebhookInOrg) canAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org) {
|
if (org && canAccess) {
|
||||||
await this.redisService.redis.set(
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
@@ -62,6 +62,12 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsWebhookInOrg - webhook with id=${webhookId} is not part of the organization with id=${organizationId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { createMock } from "@golevelup/ts-jest";
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
@@ -28,7 +29,21 @@ describe("PermissionsGuard", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
createMock<OAuthClientRepository>()
|
createMock<OAuthClientRepository>(),
|
||||||
|
createMock<OAuthClientsOutputService>({
|
||||||
|
transformOAuthClientPermission: jest.fn().mockImplementation((permission: number) => {
|
||||||
|
switch (permission) {
|
||||||
|
case SCHEDULE_WRITE:
|
||||||
|
return "SCHEDULE_WRITE";
|
||||||
|
case SCHEDULE_READ:
|
||||||
|
return "SCHEDULE_READ";
|
||||||
|
case APPS_WRITE:
|
||||||
|
return "APPS_WRITE";
|
||||||
|
default:
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,13 +51,22 @@ describe("PermissionsGuard", () => {
|
|||||||
expect(guard).toBeDefined();
|
expect(guard).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getMockOAuthClient(permissions: number) {
|
||||||
|
return {
|
||||||
|
id: "100",
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("when access token is missing", () => {
|
describe("when access token is missing", () => {
|
||||||
it("should return false", async () => {
|
it("should return false", async () => {
|
||||||
const mockContext = createMockExecutionContext({});
|
const mockContext = createMockExecutionContext({});
|
||||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
jest.spyOn(guard, "getOAuthClientByAccessToken").mockResolvedValue(getMockOAuthClient(0));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +77,9 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +90,9 @@ describe("PermissionsGuard", () => {
|
|||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
oAuthClientPermissions |= SCHEDULE_READ;
|
oAuthClientPermissions |= SCHEDULE_READ;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
@@ -75,7 +103,9 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,9 +115,13 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= APPS_WRITE;
|
oAuthClientPermissions |= APPS_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false for a missing permission", async () => {
|
it("should return false for a missing permission", async () => {
|
||||||
@@ -96,9 +130,13 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,7 +147,7 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +158,7 @@ describe("PermissionsGuard", () => {
|
|||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
oAuthClientPermissions |= SCHEDULE_READ;
|
oAuthClientPermissions |= SCHEDULE_READ;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
@@ -131,7 +169,7 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,9 +179,11 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= APPS_WRITE;
|
oAuthClientPermissions |= APPS_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false for a missing permission", async () => {
|
it("should return false for a missing permission", async () => {
|
||||||
@@ -152,9 +192,11 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { isApiKey } from "@/lib/api-key";
|
import { isApiKey } from "@/lib/api-key";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { hasPermissions } from "@calcom/platform-utils";
|
import { hasPermissions } from "@calcom/platform-utils";
|
||||||
|
import { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
@@ -16,7 +18,8 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
private reflector: Reflector,
|
private reflector: Reflector,
|
||||||
private tokensRepository: TokensRepository,
|
private tokensRepository: TokensRepository,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -27,42 +30,61 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
|
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||||
|
|
||||||
if (nextAuthToken) {
|
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
||||||
|
if (nextAuthToken || apiKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authString && !oAuthClientId) {
|
if (!bearerToken && !oAuthClientId) {
|
||||||
return false;
|
throw new ForbiddenException(
|
||||||
|
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// only check permissions for accessTokens attached to an oAuth Client
|
const oAuthClient = bearerToken
|
||||||
if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
|
? await this.getOAuthClientByAccessToken(bearerToken)
|
||||||
return true;
|
: await this.getOAuthClientById(oAuthClientId);
|
||||||
|
|
||||||
|
const hasRequiredPermissions = hasPermissions(oAuthClient.permissions, [...requiredPermissions]);
|
||||||
|
|
||||||
|
if (!hasRequiredPermissions) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PermissionsGuard - oAuth client with id=${
|
||||||
|
oAuthClient.id
|
||||||
|
} does not have the required permissions=${requiredPermissions
|
||||||
|
.map((permission) => this.oAuthClientsOutputService.transformOAuthClientPermission(permission))
|
||||||
|
.join(
|
||||||
|
", "
|
||||||
|
)}. Go to platform dashboard settings and add the required permissions to the oAuth client.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oAuthClientPermissions = authString
|
return true;
|
||||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
|
||||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
|
||||||
|
|
||||||
if (!oAuthClientPermissions) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
async getOAuthClientByAccessToken(
|
||||||
|
accessToken: string
|
||||||
|
): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||||
return oAuthClient?.permissions;
|
if (!oAuthClient) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PermissionsGuard - no oAuth client found for access token=${accessToken}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOAuthClientPermissionsById(id: string) {
|
async getOAuthClientById(id: string): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||||
return oAuthClient?.permissions;
|
if (!oAuthClient) {
|
||||||
|
throw new ForbiddenException(`PermissionsGuard - no oAuth client found for client id=${id}`);
|
||||||
|
}
|
||||||
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,27 @@ export class RolesGuard implements CanActivate {
|
|||||||
const user = request.user as ApiAuthGuardUser;
|
const user = request.user as ApiAuthGuardUser;
|
||||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||||
return canAccess;
|
|
||||||
|
if (!canAccess) {
|
||||||
|
this.throwForbiddenError(user, orgId, teamId, allowedRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throwForbiddenError(user: ApiAuthGuardUser, orgId: string, teamId: string, allowedRole: string) {
|
||||||
|
let errorMessage = `RolesGuard - user with id=${user.id} does not have the minimum required role=${allowedRole} within`;
|
||||||
|
if (orgId) {
|
||||||
|
errorMessage += ` organization with id=${orgId}`;
|
||||||
|
}
|
||||||
|
if (teamId) {
|
||||||
|
errorMessage += ` team with id=${teamId}`;
|
||||||
|
}
|
||||||
|
errorMessage += `.`;
|
||||||
|
|
||||||
|
throw new ForbiddenException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
async checkUserRoleAccess(
|
async checkUserRoleAccess(
|
||||||
user: ApiAuthGuardUser,
|
user: ApiAuthGuardUser,
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -67,7 +86,9 @@ export class RolesGuard implements CanActivate {
|
|||||||
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not a member of the organization.`);
|
throw new ForbiddenException(
|
||||||
|
`RolesGuard - User is not a member of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||||
@@ -84,7 +105,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not a member of the team.`);
|
throw new ForbiddenException(`RolesGuard - User is not a member of the team with id=${teamId}.`);
|
||||||
}
|
}
|
||||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||||
canAccess = hasMinimumRole({
|
canAccess = hasMinimumRole({
|
||||||
@@ -102,7 +123,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
if (!orgMembership) {
|
if (!orgMembership) {
|
||||||
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not part of the organization.`);
|
throw new ForbiddenException(`RolesGuard - User is not part of the organization with id=${orgId}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the role checked is a TEAM role
|
// if the role checked is a TEAM role
|
||||||
@@ -116,7 +137,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
||||||
);
|
);
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
`RolesGuard - User is not part of the team with id=${teamId} and/or, is not an admin nor an owner of the organization with id=${orgId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +159,11 @@ export class RolesGuard implements CanActivate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
|
||||||
|
if (canAccess) {
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
|
}
|
||||||
|
|
||||||
return { canAccess };
|
return { canAccess };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +190,7 @@ export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
|
|||||||
|
|
||||||
// minimum role given does not exist
|
// minimum role given does not exist
|
||||||
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
||||||
throw new Error("Invalid role");
|
throw new Error("RolesGuard - Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkedRoleIndex <= requiredRoleIndex;
|
return checkedRoleIndex <= requiredRoleIndex;
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
||||||
import {
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
@@ -20,18 +14,18 @@ export class IsRoutingFormInTeam implements CanActivate {
|
|||||||
const routingFormId: string = request.params.routingFormId;
|
const routingFormId: string = request.params.routingFormId;
|
||||||
|
|
||||||
if (!routingFormId) {
|
if (!routingFormId) {
|
||||||
throw new ForbiddenException("No routing form id found in request params.");
|
throw new ForbiddenException("IsRoutingFormInTeam - No routing form id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsRoutingFormInTeam - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
|
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
|
||||||
|
|
||||||
if (!routingForm) {
|
if (!routingForm) {
|
||||||
throw new NotFoundException(
|
throw new ForbiddenException(
|
||||||
`Team with id=(${teamId}) routing form with id=(${routingFormId}) not found.`
|
`IsRoutingFormInTeam - team with id=(${teamId}) does not own routing form with id=(${routingFormId}).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,26 +20,30 @@ export class IsTeamInOrg implements CanActivate {
|
|||||||
const orgId: string = request.params.orgId;
|
const orgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsTeamInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsTeamInOrg - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||||
|
|
||||||
if (canAccess && team) {
|
if (!canAccess) {
|
||||||
request.team = team;
|
throw new ForbiddenException(
|
||||||
|
`IsTeamInOrg - Team with id=${teamId} is not part of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return canAccess;
|
|
||||||
|
request.team = team;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
throw new NotFoundException(`IsTeamInOrg - Team (${teamId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ export class IsUserInOrgTeam implements CanActivate {
|
|||||||
const userId: string = request.params.userId;
|
const userId: string = request.params.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new ForbiddenException("No user id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No user id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.organizationsRepository.findOrgTeamUser(
|
const user = await this.organizationsRepository.findOrgTeamUser(
|
||||||
@@ -37,6 +37,8 @@ export class IsUserInOrgTeam implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
throw new ForbiddenException(
|
||||||
|
`IsUserInOrgTeam - user with id=(${userId}) is not part of the team with id=(${teamId}) in the organization with id=(${orgId})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,20 +14,22 @@ export class IsUserInOrg implements CanActivate {
|
|||||||
const userId: string = request.params.userId;
|
const userId: string = request.params.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new ForbiddenException("No user id found in request params.");
|
throw new ForbiddenException("IsUserInOrg - No user id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsUserInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||||
|
|
||||||
if (user) {
|
if (!user) {
|
||||||
request.user = user;
|
throw new ForbiddenException(
|
||||||
return true;
|
`IsUserInOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
request.user = user;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ import { getToken } from "next-auth/jwt";
|
|||||||
|
|
||||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||||
|
|
||||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||||
export type ApiAuthGuardRequest = Request & {
|
export type ApiAuthGuardRequest = Request & {
|
||||||
authMethod: AuthMethods;
|
authMethod: AuthMethods;
|
||||||
organizationId: number | null;
|
organizationId: number | null;
|
||||||
user: ApiAuthGuardUser;
|
user: ApiAuthGuardUser;
|
||||||
|
allowedAuthMethods?: AllowedAuthMethod[];
|
||||||
};
|
};
|
||||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||||
@@ -49,12 +52,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||||
|
|
||||||
if (oAuthClientId && oAuthClientSecret) {
|
const allowedMethods = request.allowedAuthMethods;
|
||||||
|
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
||||||
|
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
||||||
|
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
||||||
|
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
||||||
|
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
||||||
|
|
||||||
|
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
||||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bearerToken) {
|
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
|
||||||
const requestOrigin = request.get("Origin");
|
const requestOrigin = request.get("Origin");
|
||||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||||
? AuthMethods["API_KEY"]
|
? AuthMethods["API_KEY"]
|
||||||
@@ -64,19 +74,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||||
|
if (nextAuthToken && nextAuthAllowed) {
|
||||||
if (nextAuthToken) {
|
|
||||||
request.authMethod = AuthMethods["NEXT_AUTH"];
|
request.authMethod = AuthMethods["NEXT_AUTH"];
|
||||||
return await this.authenticateNextAuth(nextAuthToken, request);
|
return await this.authenticateNextAuth(nextAuthToken, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
|
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
|
if (noAuthProvided) {
|
||||||
|
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return this.error(err);
|
return this.error(err);
|
||||||
}
|
}
|
||||||
return this.error(
|
return this.error(
|
||||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
new InternalServerErrorException(
|
||||||
|
"ApiAuthStrategy - An error occurred while authenticating the request"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,23 +123,29 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
throw new UnauthorizedException(
|
||||||
|
`ApiAuthStrategy - oAuth client - Client with ID ${oAuthClientId} not found`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.secret !== oAuthClientSecret) {
|
if (client.secret !== oAuthClientSecret) {
|
||||||
throw new UnauthorizedException("Invalid client secret");
|
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||||
|
|
||||||
if (!platformCreatorId) {
|
if (!platformCreatorId) {
|
||||||
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - oAuth client - No owner ID found for this OAuth client"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - oAuth client - No user associated with the provided OAuth client"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.organizationId = client.organizationId;
|
request.organizationId = client.organizationId;
|
||||||
@@ -141,7 +164,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return this.error(new UnauthorizedException("No user associated with the provided token"));
|
return this.error(
|
||||||
|
new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success(this.getSuccessUser(user));
|
return this.success(this.getSuccessUser(user));
|
||||||
@@ -158,24 +185,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
|
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
|
||||||
const isLicenseValid = await this.deploymentsService.checkLicense();
|
const isLicenseValid = await this.deploymentsService.checkLicense();
|
||||||
if (!isLicenseValid) {
|
if (!isLicenseValid) {
|
||||||
throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - api key - Invalid or missing CALCOM_LICENSE_KEY environment variable"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!keyData) {
|
if (!keyData) {
|
||||||
throw new UnauthorizedException("Your api key is not valid");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isKeyExpired =
|
const isKeyExpired =
|
||||||
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
|
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
|
||||||
if (isKeyExpired) {
|
if (isKeyExpired) {
|
||||||
throw new UnauthorizedException("Your api key is expired");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKeyOwnerId = keyData.userId;
|
const apiKeyOwnerId = keyData.userId;
|
||||||
if (!apiKeyOwnerId) {
|
if (!apiKeyOwnerId) {
|
||||||
throw new UnauthorizedException("No user tied to this apiKey");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - No user tied to this apiKey");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
|
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
|
||||||
@@ -187,29 +216,35 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
|
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
|
||||||
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
||||||
if (!accessTokenValid) {
|
if (!accessTokenValid) {
|
||||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
throw new UnauthorizedException(`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new UnauthorizedException("OAuth client not found given the access token");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - access token - OAuth client not found given the access token"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
|
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
`ApiAuthStrategy - access token - Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|
||||||
if (!ownerId) {
|
if (!ownerId) {
|
||||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
throw new UnauthorizedException(
|
||||||
|
`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}. No owner found for this access token.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
|
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication api key not found.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - access token - User associated with the access token not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||||
@@ -220,12 +255,16 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
|
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
|
||||||
if (!token.email) {
|
if (!token.email) {
|
||||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - next auth - Email not found in the authentication token."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmailWithProfile(token.email);
|
const user = await this.userRepository.findByEmailWithProfile(token.email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - next auth - User associated with the authentication token email not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||||
request.organizationId = organizationId;
|
request.organizationId = organizationId;
|
||||||
|
|||||||
@@ -18,23 +18,27 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy,
|
|||||||
const payload = await getToken({ req, secret: nextAuthSecret });
|
const payload = await getToken({ req, secret: nextAuthSecret });
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new UnauthorizedException("Authentication token is missing or invalid.");
|
throw new UnauthorizedException("NextAuthStrategy - Authentication token is missing or invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.email) {
|
if (!payload.email) {
|
||||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
throw new UnauthorizedException("NextAuthStrategy - Email not found in the authentication token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmailWithProfile(payload.email);
|
const user = await this.userRepository.findByEmailWithProfile(payload.email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
throw new UnauthorizedException(
|
||||||
|
"NextAuthStrategy - User associated with the authentication token email not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success(user);
|
return this.success(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) return this.error(error);
|
if (error instanceof Error) return this.error(error);
|
||||||
return this.error(
|
return this.error(
|
||||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
new InternalServerErrorException(
|
||||||
|
"NextAuthStrategy - An error occurred while authenticating the request"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
|
Delete,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
@@ -96,6 +97,19 @@ export class BillingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete("/:organizationId/unsubscribe")
|
||||||
|
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||||
|
@MembershipRoles(["OWNER", "ADMIN"])
|
||||||
|
async cancelTeamSubscriptionInStripe(
|
||||||
|
@Param("organizationId") organizationId: number
|
||||||
|
): Promise<ApiResponse> {
|
||||||
|
await this.billingService.cancelTeamSubscription(organizationId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/webhook")
|
@Post("/webhook")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async stripeWebhook(
|
async stripeWebhook(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
|||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { InjectQueue } from "@nestjs/bull";
|
import { InjectQueue } from "@nestjs/bull";
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -313,6 +314,28 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelTeamSubscription(teamId: number) {
|
||||||
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
|
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new NotFoundException("No customer id found for team in Stripe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||||
|
throw new NotFoundException("Team plan not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.stripeService
|
||||||
|
.getStripe()
|
||||||
|
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log(error, "error while cancelling team subscription in stripe");
|
||||||
|
throw new BadRequestException("Failed to cancel team subscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
|
|
||||||
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
||||||
|
|
||||||
const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`;
|
const getLicenseCacheKey = (key: string) => `api-v2-license-key-goblin-url-${key}`;
|
||||||
|
|
||||||
type LicenseCheckResponse = {
|
type LicenseCheckResponse = {
|
||||||
valid: boolean;
|
status: boolean;
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeploymentsService {
|
export class DeploymentsService {
|
||||||
@@ -36,12 +36,12 @@ export class DeploymentsService {
|
|||||||
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
||||||
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
|
return (JSON.parse(cachedData) as LicenseCheckResponse)?.status;
|
||||||
}
|
}
|
||||||
const response = await fetch(licenseKeyUrl, { mode: "cors" });
|
const response = await fetch(licenseKeyUrl, { mode: "cors" });
|
||||||
const data = (await response.json()) as LicenseCheckResponse;
|
const data = (await response.json()) as LicenseCheckResponse;
|
||||||
const cacheKey = getLicenseCacheKey(licenseKey);
|
const cacheKey = getLicenseCacheKey(licenseKey);
|
||||||
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
|
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
|
||||||
return data.valid;
|
return data.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
|
|||||||
import { RouterModule } from "@/modules/router/router.module";
|
import { RouterModule } from "@/modules/router/router.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||||
|
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
|
||||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
|||||||
OrganizationsUsersBookingsModule,
|
OrganizationsUsersBookingsModule,
|
||||||
OrganizationsBookingsModule,
|
OrganizationsBookingsModule,
|
||||||
OrganizationsRoutingFormsModule,
|
OrganizationsRoutingFormsModule,
|
||||||
|
VerifiedResourcesModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import type { DB } from "@calcom/kysely/types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KyselyReadService implements OnModuleDestroy {
|
||||||
|
private logger = new Logger("KyselyReadService");
|
||||||
|
|
||||||
|
public kysely: Kysely<DB>;
|
||||||
|
|
||||||
|
constructor(readonly configService: ConfigService) {
|
||||||
|
const dbUrl = configService.get("db.readUrl", { infer: true });
|
||||||
|
const pool = new Pool({ connectionString: dbUrl });
|
||||||
|
|
||||||
|
// 3. Create the Dialect, passing the configured pool instance
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: pool, // Use the pool instance created above
|
||||||
|
});
|
||||||
|
this.kysely = new Kysely<DB>({
|
||||||
|
dialect,
|
||||||
|
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
await this.kysely.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OnModuleDestroy } from "@nestjs/common";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import type { DB } from "@calcom/kysely/types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KyselyWriteService implements OnModuleDestroy {
|
||||||
|
private logger = new Logger("KyselyWriteService");
|
||||||
|
|
||||||
|
public kysely: Kysely<DB>;
|
||||||
|
|
||||||
|
constructor(readonly configService: ConfigService) {
|
||||||
|
const dbUrl = configService.get("db.writeUrl", { infer: true });
|
||||||
|
const pool = new Pool({ connectionString: dbUrl });
|
||||||
|
|
||||||
|
// 3. Create the Dialect, passing the configured pool instance
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: pool, // Use the pool instance created above
|
||||||
|
});
|
||||||
|
this.kysely = new Kysely<DB>({
|
||||||
|
dialect,
|
||||||
|
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
await this.kysely.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
|
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [KyselyReadService, KyselyWriteService],
|
||||||
|
exports: [KyselyReadService, KyselyWriteService],
|
||||||
|
})
|
||||||
|
export class KyselyModule {}
|
||||||
@@ -63,6 +63,7 @@ export class MembershipsRepository {
|
|||||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||||
const membership = await this.dbRead.prisma.membership.create({
|
const membership = await this.dbRead.prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
role,
|
role,
|
||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Request } from "express";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthClientGuard implements CanActivate {
|
export class OAuthClientGuard implements CanActivate {
|
||||||
@@ -35,7 +34,13 @@ export class OAuthClientGuard implements CanActivate {
|
|||||||
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
|
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
const allowed = Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`OAuthClientGuard - forbidden. oAuth client with id=${oAuthClientId} does not belong to the organization with id=${organizationId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrganizationId(context: ExecutionContext) {
|
getOrganizationId(context: ExecutionContext) {
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
OAuthClientUsersOutputService,
|
OAuthClientUsersOutputService,
|
||||||
],
|
],
|
||||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||||
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
|
exports: [OAuthClientRepository, OAuthClientsOutputService, OAuthClientUsersOutputService],
|
||||||
})
|
})
|
||||||
export class OAuthClientModule {}
|
export class OAuthClientModule {}
|
||||||
|
|||||||
+13
-14
@@ -6,17 +6,8 @@ import { PlatformOAuthClientDto } from "@calcom/platform-types";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthClientsOutputService {
|
export class OAuthClientsOutputService {
|
||||||
transformPermissions(permissionsNumber: number): Array<keyof typeof PERMISSION_MAP> {
|
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
||||||
const permissionNumbers = PERMISSIONS.filter(
|
return clients.map((client) => this.transformOAuthClient(client));
|
||||||
(permission) => (permissionsNumber & permission) === permission
|
|
||||||
);
|
|
||||||
|
|
||||||
return permissionNumbers.map(
|
|
||||||
(permission) =>
|
|
||||||
Object.entries(PERMISSION_MAP).find(
|
|
||||||
([_, value]) => value === permission
|
|
||||||
)?.[0] as keyof typeof PERMISSION_MAP
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
|
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
|
||||||
@@ -24,7 +15,7 @@ export class OAuthClientsOutputService {
|
|||||||
id: client.id,
|
id: client.id,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
secret: client.secret,
|
secret: client.secret,
|
||||||
permissions: this.transformPermissions(client.permissions),
|
permissions: this.transformOAuthClientPermissions(client.permissions),
|
||||||
logo: client.logo,
|
logo: client.logo,
|
||||||
redirectUris: client.redirectUris,
|
redirectUris: client.redirectUris,
|
||||||
organizationId: client.organizationId,
|
organizationId: client.organizationId,
|
||||||
@@ -37,7 +28,15 @@ export class OAuthClientsOutputService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
transformOAuthClientPermissions(permissions: number): Array<keyof typeof PERMISSION_MAP> {
|
||||||
return clients.map((client) => this.transformOAuthClient(client));
|
const permissionsNumbers = PERMISSIONS.filter((permission) => (permissions & permission) === permission);
|
||||||
|
|
||||||
|
return permissionsNumbers.map((permission) => this.transformOAuthClientPermission(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
transformOAuthClientPermission(permission: (typeof PERMISSIONS)[number]): keyof typeof PERMISSION_MAP {
|
||||||
|
return Object.entries(PERMISSION_MAP).find(
|
||||||
|
([_, value]) => value === permission
|
||||||
|
)?.[0] as keyof typeof PERMISSION_MAP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||||
expect(data.color).toEqual(body.color);
|
expect(data.color).toEqual(body.color);
|
||||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||||
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||||
expect(data.color).toEqual(body.color);
|
expect(data.color).toEqual(body.color);
|
||||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type Input = Pick<
|
|||||||
| "eventName"
|
| "eventName"
|
||||||
| "useEventTypeDestinationCalendarEmail"
|
| "useEventTypeDestinationCalendarEmail"
|
||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
|
| "hideOrganizerEmail"
|
||||||
| "team"
|
| "team"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
+10
@@ -10,6 +10,16 @@ export class CreateOrganizationInput extends RefreshApiKeyInput {
|
|||||||
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
||||||
readonly name!: string;
|
readonly name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description:
|
||||||
|
"Organization slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||||
|
example: "cal-tel",
|
||||||
|
})
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Object,
|
type: Object,
|
||||||
description: METADATA_DOCS,
|
description: METADATA_DOCS,
|
||||||
|
|||||||
+2
@@ -46,6 +46,7 @@ import {
|
|||||||
X_CAL_SECRET_KEY,
|
X_CAL_SECRET_KEY,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { slugify } from "@calcom/platform-libraries";
|
||||||
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
|
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ describe("Organizations Organizations Endpoints", () => {
|
|||||||
managedOrg = responseBody.data;
|
managedOrg = responseBody.data;
|
||||||
expect(managedOrg?.id).toBeDefined();
|
expect(managedOrg?.id).toBeDefined();
|
||||||
expect(managedOrg?.name).toEqual(body.name);
|
expect(managedOrg?.name).toEqual(body.name);
|
||||||
|
expect(managedOrg?.slug).toEqual(slugify(body.name));
|
||||||
expect(managedOrg?.metadata).toEqual(body.metadata);
|
expect(managedOrg?.metadata).toEqual(body.metadata);
|
||||||
expect(managedOrg?.apiKey).toBeDefined();
|
expect(managedOrg?.apiKey).toBeDefined();
|
||||||
|
|
||||||
|
|||||||
+6
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
readonly name!: string;
|
readonly name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Expose()
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Object,
|
type: Object,
|
||||||
example: { key: "value" },
|
example: { key: "value" },
|
||||||
|
|||||||
+6
@@ -10,6 +10,8 @@ import { ManagedOrganizationsOutputService } from "@/modules/organizations/organ
|
|||||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { slugify } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManagedOrganizationsService {
|
export class ManagedOrganizationsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -36,6 +38,10 @@ export class ManagedOrganizationsService {
|
|||||||
|
|
||||||
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
||||||
|
|
||||||
|
if (!organizationData.slug) {
|
||||||
|
organizationData.slug = slugify(organizationData.name);
|
||||||
|
}
|
||||||
|
|
||||||
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
||||||
managerOrganizationId,
|
managerOrganizationId,
|
||||||
{
|
{
|
||||||
|
|||||||
+40
-30
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
|
|
||||||
where: {
|
|
||||||
formId: routingForm.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.team.delete({
|
|
||||||
where: {
|
|
||||||
id: team.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prismaWriteService.prisma.team.delete({
|
|
||||||
where: {
|
|
||||||
id: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||||
it("should not get routing form responses for non existing org", async () => {
|
it("should not get routing form responses for non existing org", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||||
|
where: {
|
||||||
|
id: routingFormResponse.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||||
|
where: {
|
||||||
|
id: routingFormResponse2.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.team.delete({
|
||||||
|
where: {
|
||||||
|
id: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.team.delete({
|
||||||
|
where: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismaWriteService.prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+39
-2
@@ -1,13 +1,18 @@
|
|||||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import {
|
import {
|
||||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||||
OPTIONAL_API_KEY_HEADER,
|
OPTIONAL_API_KEY_HEADER,
|
||||||
|
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||||
} from "@/lib/docs/headers";
|
} from "@/lib/docs/headers";
|
||||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
|||||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
|
||||||
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
|||||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
export class OrganizationsTeamsBookingsController {
|
export class OrganizationsTeamsBookingsController {
|
||||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
constructor(
|
||||||
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
|
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({ summary: "Get organization team bookings" })
|
@ApiOperation({ summary: "Get organization team bookings" })
|
||||||
@@ -56,4 +64,33 @@ export class OrganizationsTeamsBookingsController {
|
|||||||
data: bookings,
|
data: bookings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid/references")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@UseGuards(
|
||||||
|
ApiAuthGuard,
|
||||||
|
BookingUidGuard,
|
||||||
|
IsOrgGuard,
|
||||||
|
RolesGuard,
|
||||||
|
IsTeamInOrg,
|
||||||
|
PlatformPlanGuard,
|
||||||
|
IsAdminAPIEnabledGuard
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get 'Booking References' for a booking",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getBookingReferences(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||||
|
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||||
|
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookingReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,8 @@
|
|||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
||||||
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
||||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
providers: [
|
||||||
|
OrganizationsRepository,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
BookingReferencesService_2024_08_13,
|
||||||
|
BookingReferencesRepository_2024_08_13,
|
||||||
|
BookingsRepository_2024_08_13,
|
||||||
|
OutputBookingReferencesService_2024_08_13,
|
||||||
|
],
|
||||||
controllers: [OrganizationsTeamsBookingsController],
|
controllers: [OrganizationsTeamsBookingsController],
|
||||||
})
|
})
|
||||||
export class OrganizationsTeamsBookingsModule {}
|
export class OrganizationsTeamsBookingsModule {}
|
||||||
|
|||||||
+28
@@ -36,6 +36,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
let team2: Team;
|
let team2: Team;
|
||||||
let teamCreatedViaApi: Team;
|
let teamCreatedViaApi: Team;
|
||||||
let teamCreatedViaApi2: Team;
|
let teamCreatedViaApi2: Team;
|
||||||
|
let teamCreatedViaApi3: Team;
|
||||||
|
|
||||||
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
|
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
|
||||||
let user: User;
|
let user: User;
|
||||||
@@ -245,11 +246,38 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create the team of the org with automatically set slug", async () => {
|
||||||
|
const teamName = `Organizations Teams Automatic Slug`;
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/teams`)
|
||||||
|
.send({
|
||||||
|
name: teamName,
|
||||||
|
bio: "This is our test team created via API",
|
||||||
|
} satisfies CreateOrgTeamDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
teamCreatedViaApi3 = responseBody.data;
|
||||||
|
expect(teamCreatedViaApi3.name).toEqual(teamName);
|
||||||
|
expect(teamCreatedViaApi3.slug).toEqual("organizations-teams-automatic-slug");
|
||||||
|
expect(teamCreatedViaApi3.bio).toEqual("This is our test team created via API");
|
||||||
|
expect(teamCreatedViaApi3.parentId).toEqual(org.id);
|
||||||
|
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||||
|
user.id,
|
||||||
|
teamCreatedViaApi3.id
|
||||||
|
);
|
||||||
|
expect(membership?.role ?? "").toEqual("OWNER");
|
||||||
|
expect(membership?.accepted).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await teamsRepositoryFixture.delete(team.id);
|
await teamsRepositoryFixture.delete(team.id);
|
||||||
await teamsRepositoryFixture.delete(team2.id);
|
await teamsRepositoryFixture.delete(team2.id);
|
||||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||||
|
await teamsRepositoryFixture.delete(teamCreatedViaApi3.id);
|
||||||
await teamsRepositoryFixture.delete(org.id);
|
await teamsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
+10
@@ -5,6 +5,8 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { slugify } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsTeamsService {
|
export class OrganizationsTeamsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,6 +42,10 @@ export class OrganizationsTeamsService {
|
|||||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
||||||
const { autoAcceptCreator, ...rest } = data;
|
const { autoAcceptCreator, ...rest } = data;
|
||||||
|
|
||||||
|
if (!rest.slug) {
|
||||||
|
rest.slug = slugify(rest.name);
|
||||||
|
}
|
||||||
|
|
||||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
||||||
|
|
||||||
if (user.role !== "ADMIN") {
|
if (user.role !== "ADMIN") {
|
||||||
@@ -56,6 +62,10 @@ export class OrganizationsTeamsService {
|
|||||||
) {
|
) {
|
||||||
const { autoAcceptCreator, ...rest } = data;
|
const { autoAcceptCreator, ...rest } = data;
|
||||||
|
|
||||||
|
if (!rest.slug) {
|
||||||
|
rest.slug = slugify(rest.name);
|
||||||
|
}
|
||||||
|
|
||||||
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
||||||
organizationId,
|
organizationId,
|
||||||
oAuthClientId,
|
oAuthClientId,
|
||||||
|
|||||||
+5
-1
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
|
|||||||
|
|
||||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||||
return this.dbWrite.prisma.membership.create({
|
return this.dbWrite.prisma.membership.create({
|
||||||
data: { ...data, teamId: teamId },
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
teamId: teamId,
|
||||||
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
include: { user: { select: MembershipUserSelect } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
|
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let orgTeam: Team;
|
let orgTeam: Team;
|
||||||
let routingFormResponseId: string;
|
let routingFormResponseId: number;
|
||||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||||
let user: User;
|
let user: User;
|
||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
let routingFormId: string;
|
let routingFormId: string;
|
||||||
const routingFormResponses = [
|
const routingFormResponses = [
|
||||||
{
|
{
|
||||||
id: 1,
|
formFillerId: `${randomString()}`,
|
||||||
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
|
|
||||||
response: {
|
response: {
|
||||||
"participant-field": {
|
"participant-field": {
|
||||||
label: "participant",
|
label: "participant",
|
||||||
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
const responseData = responseBody.data;
|
const responseData = responseBody.data;
|
||||||
expect(responseData).toBeDefined();
|
expect(responseData).toBeDefined();
|
||||||
expect(responseData.length).toEqual(1);
|
expect(responseData.length).toEqual(1);
|
||||||
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
|
|
||||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||||
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||||
|
await routingFormsRepositoryFixture.delete(routingFormId);
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
+2
@@ -6,6 +6,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
|||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||||
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
|||||||
IsTeamInOrg,
|
IsTeamInOrg,
|
||||||
IsRoutingFormInTeam,
|
IsRoutingFormInTeam,
|
||||||
PlatformPlanGuard,
|
PlatformPlanGuard,
|
||||||
|
RolesGuard,
|
||||||
IsAdminAPIEnabledGuard
|
IsAdminAPIEnabledGuard
|
||||||
)
|
)
|
||||||
@ApiHeader(API_KEY_HEADER)
|
@ApiHeader(API_KEY_HEADER)
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||||
|
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||||
|
import {
|
||||||
|
TeamVerifiedEmailOutput,
|
||||||
|
TeamVerifiedEmailsOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedPhoneOutput,
|
||||||
|
TeamVerifiedPhonesOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { TOTP as TOTPtoMock } from "@otplib/core";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { totp } from "otplib";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
jest.spyOn(totp, "generate").mockImplementation(function () {
|
||||||
|
return "1234";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
|
||||||
|
.mockImplementation(async function () {
|
||||||
|
return {
|
||||||
|
to: `testnotrealemail@notreal.com`,
|
||||||
|
from: `testnotrealemail@notreal.com`,
|
||||||
|
subject: "No Subject",
|
||||||
|
html: "<html><body>Mocked Email Content</body></html>",
|
||||||
|
text: "body",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Organizations Teams Verified Resources", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
|
||||||
|
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
|
let org: Team;
|
||||||
|
let orgTeam: Team;
|
||||||
|
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
|
||||||
|
const phoneToVerify = "+37255556666";
|
||||||
|
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
|
||||||
|
let user: User;
|
||||||
|
let apiKeyString: string;
|
||||||
|
let verifiedEmailId: number;
|
||||||
|
let verifiedPhoneId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
org = await organizationsRepositoryFixture.create({
|
||||||
|
name: `organizations-verified-resources-responses-organization-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: authEmail,
|
||||||
|
username: authEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
|
|
||||||
|
orgTeam = await teamsRepositoryFixture.create({
|
||||||
|
name: `organizations-verified-resources-responses-team-${randomString()}`,
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: orgTeam.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${user.id}`,
|
||||||
|
username: authEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
verifiedPhoneId = (
|
||||||
|
await verifiedResourcesRepositoryFixtures.createPhone({
|
||||||
|
phoneNumber: phoneToVerify,
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: {
|
||||||
|
connect: {
|
||||||
|
id: orgTeam.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger email verification code", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(
|
||||||
|
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
|
||||||
|
)
|
||||||
|
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify email", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(
|
||||||
|
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
|
||||||
|
)
|
||||||
|
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailOutput;
|
||||||
|
verifiedEmailId = response.data.id;
|
||||||
|
expect(response.data.email).toEqual(emailToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified email by id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailOutput;
|
||||||
|
expect(response.data.email).toEqual(emailToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified number by id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedPhoneOutput;
|
||||||
|
expect(response.data.phoneNumber).toEqual(phoneToVerify);
|
||||||
|
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified emails", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedEmailsOutput;
|
||||||
|
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
|
||||||
|
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch verified phones", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const response = res.body as TeamVerifiedPhonesOutput;
|
||||||
|
expect(
|
||||||
|
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
|
||||||
|
).toBeDefined();
|
||||||
|
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
|
||||||
|
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
|
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||||
|
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
|
||||||
|
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||||
|
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
|
||||||
|
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
|
||||||
|
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedEmailOutput,
|
||||||
|
TeamVerifiedEmailOutputData,
|
||||||
|
TeamVerifiedEmailsOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||||
|
import {
|
||||||
|
TeamVerifiedPhoneOutput,
|
||||||
|
TeamVerifiedPhoneOutputData,
|
||||||
|
TeamVerifiedPhonesOutput,
|
||||||
|
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||||
|
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
|
||||||
|
})
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiTags("Organization Team Verified Resources")
|
||||||
|
export class OrgTeamsVerifiedResourcesController {
|
||||||
|
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Request Email Verification Code",
|
||||||
|
description: `Sends a verification code to the email.`,
|
||||||
|
})
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_emails_requests",
|
||||||
|
})
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Post("/emails/verification-code/request")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async requestEmailVerificationCode(
|
||||||
|
@Body() body: RequestEmailVerificationInput,
|
||||||
|
@GetUser("username") username: string,
|
||||||
|
@GetUser("locale") locale: string
|
||||||
|
): Promise<RequestEmailVerificationOutput> {
|
||||||
|
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
|
||||||
|
{ username, locale },
|
||||||
|
body.email
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Request Phone Number Verification Code",
|
||||||
|
description: `Sends a verification code to the phone number.`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_phones_requests",
|
||||||
|
})
|
||||||
|
@Post("/phones/verification-code/request")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async requestPhoneVerificationCode(
|
||||||
|
@Body() body: RequestPhoneVerificationInput
|
||||||
|
): Promise<RequestPhoneVerificationOutput> {
|
||||||
|
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
|
||||||
|
body.phone
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Verify an email for an org team.",
|
||||||
|
description: `Use code to verify an email`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Post("/emails/verification-code/verify")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyEmail(
|
||||||
|
@Body() body: VerifyEmailInput,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedEmailOutput> {
|
||||||
|
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
|
||||||
|
userId,
|
||||||
|
body.email,
|
||||||
|
body.code,
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Verify a phone number for an org team.",
|
||||||
|
description: `Use code to verify a phone number`,
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Post("/phones/verification-code/verify")
|
||||||
|
@Throttle({
|
||||||
|
limit: 3,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "org_teams_verified_resources_phones_verify",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyPhoneNumber(
|
||||||
|
@Body() body: VerifyPhoneInput,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedPhoneOutput> {
|
||||||
|
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
|
||||||
|
userId,
|
||||||
|
body.phone,
|
||||||
|
body.code,
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get list of verified emails of an org team.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/emails")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedEmails(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Query() pagination: SkipTakePagination
|
||||||
|
): Promise<TeamVerifiedEmailsOutput> {
|
||||||
|
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
|
||||||
|
teamId,
|
||||||
|
pagination?.skip ?? 0,
|
||||||
|
pagination?.take ?? 250
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get list of verified phone numbers of an org team.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/phones")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedPhoneNumbers(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Query() pagination: SkipTakePagination
|
||||||
|
): Promise<TeamVerifiedPhonesOutput> {
|
||||||
|
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
|
||||||
|
teamId,
|
||||||
|
pagination?.skip ?? 0,
|
||||||
|
pagination?.take ?? 250
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
|
||||||
|
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get verified email of an org team by id.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/emails/:id")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedEmailById(
|
||||||
|
@Param("id") id: number,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<TeamVerifiedEmailOutput> {
|
||||||
|
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get verified phone number of an org team by id.",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/phones/:id")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getVerifiedPhoneById(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("id") id: number
|
||||||
|
): Promise<TeamVerifiedPhoneOutput> {
|
||||||
|
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
|
||||||
|
teamId,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
|
|||||||
member: {
|
member: {
|
||||||
teamId: orgId,
|
teamId: orgId,
|
||||||
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
||||||
// Filter to only get users which have ALL of the assigned attribue options
|
// Filter to only get users which have ALL of the assigned attribute options
|
||||||
...(attributeQueryOperator === "AND" && {
|
...(attributeQueryOperator === "AND" && {
|
||||||
AND: assignedOptionIds.map((optionId) => ({
|
AND: assignedOptionIds.map((optionId) => ({
|
||||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||||
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||||
// Filter to get users which have AT LEAST ONE of the assigned attribue options
|
// Filter to get users which have AT LEAST ONE of the assigned attribute options
|
||||||
...(attributeQueryOperator === "OR" && {
|
...(attributeQueryOperator === "OR" && {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
}),
|
}),
|
||||||
// Filter to get users that have NONE the assigned attribue options
|
// Filter to get users that have NONE the assigned attribute options
|
||||||
...(attributeQueryOperator === "NONE" && {
|
...(attributeQueryOperator === "NONE" && {
|
||||||
NOT: {
|
NOT: {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
|
|||||||
return await this.dbWrite.prisma.user.delete({
|
return await this.dbWrite.prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
organizationId: orgId,
|
OR: [
|
||||||
|
{ organizationId: orgId },
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
some: {
|
||||||
|
organizationId: orgId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
|
||||||
// TODO: adapt if required for CF / AWS / FlightControl proxying.
|
|
||||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
|
||||||
return req.ips.length ? req.ips[0] : req.ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user