Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4469f6e5 | ||
|
|
5401bcca4e | ||
|
|
f9afca2939 | ||
|
|
fc1d553b74 | ||
|
|
b8c2fdac44 | ||
|
|
e24c40f518 | ||
|
|
c5cefc3e61 | ||
|
|
2c57ad0288 | ||
|
|
af9bf14d9e | ||
|
|
b1a0cdc8e2 | ||
|
|
7fef3e7b85 | ||
|
|
384deab0ce | ||
|
|
22da681f8c | ||
|
|
f4f4afad39 | ||
|
|
1c05952077 | ||
|
|
1df70e75ed | ||
|
|
8962f6f08e | ||
|
|
6272c881c8 | ||
|
|
bdf94c7f48 | ||
|
|
b79c5e7c86 | ||
|
|
3eaccb8738 | ||
|
|
9273226fb4 | ||
|
|
7ce5886b4f | ||
|
|
e85c0e1a85 | ||
|
|
d294a74aad | ||
|
|
5012a16794 | ||
|
|
322ca2a12c | ||
|
|
9b79c037c2 | ||
|
|
f76696032e | ||
|
|
c6aa57177a | ||
|
|
7f2db48c72 | ||
|
|
c59e733b5a | ||
|
|
e0713d78b3 | ||
|
|
d868b325c4 | ||
|
|
f8f6c31e40 | ||
|
|
7246fa4084 | ||
|
|
039a0b7a85 | ||
|
|
12c5005305 | ||
|
|
1ff6465384 | ||
|
|
ae706bb6d6 | ||
|
|
538a39328e | ||
|
|
3589767099 | ||
|
|
ff2754f663 | ||
|
|
65484e7d01 | ||
|
|
4d942a454a | ||
|
|
7dbcb62724 | ||
|
|
c197c33fd0 | ||
|
|
815065d67c | ||
|
|
02cecd6707 | ||
|
|
81386d4ec0 | ||
|
|
204e411585 | ||
|
|
1ec1554cb5 | ||
|
|
241579eba6 | ||
|
|
8ccad29ab6 | ||
|
|
74b4d7a94a | ||
|
|
5c6e60b726 | ||
|
|
18c7c3c940 | ||
|
|
fbd113b976 | ||
|
|
c7f11c6795 | ||
|
|
84fb9bb371 | ||
|
|
1aac70f643 | ||
|
|
0dde8c1db9 | ||
|
|
0da3a9fa11 | ||
|
|
eb8f5bd194 | ||
|
|
82901fee94 | ||
|
|
4e0e3a47f5 | ||
|
|
dc400d80d0 | ||
|
|
6889592d4f | ||
|
|
95b833bc1c | ||
|
|
b06683458a | ||
|
|
01d764c6d3 | ||
|
|
49333addc2 | ||
|
|
6c40d29745 | ||
|
|
fc5ad53a6d | ||
|
|
4d38c6d0c7 | ||
|
|
cd15335eed | ||
|
|
aaa75100d4 | ||
|
|
ae9063804d | ||
|
|
30f9d9b980 | ||
|
|
42a264380a | ||
|
|
62aaba32cf | ||
|
|
aaf630f01f | ||
|
|
838e8b9e89 | ||
|
|
ce373c475c | ||
|
|
ed662b7956 | ||
|
|
2e49110ce6 | ||
|
|
fc35f9de85 | ||
|
|
9c67ee7375 | ||
|
|
a1477dc7e1 | ||
|
|
25d39b4273 | ||
|
|
4976e24e12 | ||
|
|
5ae6e1e983 | ||
|
|
6080ce0c85 | ||
|
|
79f0754bf7 | ||
|
|
540bd478dc | ||
|
|
9873e373dc | ||
|
|
52093862ce | ||
|
|
e101f35b4f | ||
|
|
54cc2dc094 | ||
|
|
88e57d48fe | ||
|
|
6619701c88 | ||
|
|
ebb8bb9c67 | ||
|
|
993bf23986 | ||
|
|
4b38d73120 | ||
|
|
8f92c9b884 | ||
|
|
edecc7e60e | ||
|
|
4eefb08a0d | ||
|
|
3f7e632d5c | ||
|
|
ef0fd67646 | ||
|
|
8e7567b6a3 | ||
|
|
193855d60f | ||
|
|
92fafa7344 | ||
|
|
21aaff01fa | ||
|
|
e07a28a2d1 | ||
|
|
9a1b5ad149 | ||
|
|
1c20a257d3 | ||
|
|
656fcafcde | ||
|
|
6d7080d9fc | ||
|
|
f943debda2 | ||
|
|
803fc488c5 | ||
|
|
423b284278 | ||
|
|
f1111c9d42 | ||
|
|
ed6d88e30f | ||
|
|
e675ad0c6c | ||
|
|
a01d6aa932 | ||
|
|
f868b86e6c | ||
|
|
db0a7f3797 | ||
|
|
5646229cd4 | ||
|
|
f39e817d86 | ||
|
|
42c4c134fa | ||
|
|
ffc4b69edd | ||
|
|
7263f3b00b | ||
|
|
ddfbe25903 | ||
|
|
dd944bace1 | ||
|
|
e4353af839 | ||
|
|
ff9936fdeb | ||
|
|
e74718ac66 | ||
|
|
a78f10223f | ||
|
|
c850af7409 | ||
|
|
06dccd7a73 | ||
|
|
3b59185f75 | ||
|
|
c907bcc646 | ||
|
|
6c5b12db57 | ||
|
|
bcc1a8e279 | ||
|
|
a277b8515f | ||
|
|
42a02cbb60 | ||
|
|
0c60ad1c82 | ||
|
|
1add8e73ee | ||
|
|
ff15ec77c3 | ||
|
|
b2d395de52 | ||
|
|
9dd35c7ab3 | ||
|
|
bd30bf335b | ||
|
|
ab65ce73c2 | ||
|
|
06f83e6d0d | ||
|
|
ebd5ca6ceb | ||
|
|
bcaf067ed6 | ||
|
|
120cfed8a1 | ||
|
|
e728ed74ce | ||
|
|
64551a506a | ||
|
|
fee3b73bda | ||
|
|
a5806cb100 | ||
|
|
f649f8b78d | ||
|
|
ced7f282a5 | ||
|
|
dfa4e10c83 | ||
|
|
c3f03c8000 | ||
|
|
aaa5531a3a | ||
|
|
2784564427 | ||
|
|
4b6b9badf5 | ||
|
|
5a9197f357 | ||
|
|
315077f6c5 | ||
|
|
b8b270e4e5 | ||
|
|
04a49e74d4 | ||
|
|
8605fb4518 | ||
|
|
55671db5fd | ||
|
|
1a55f2d8e2 | ||
|
|
b82b39d12d | ||
|
|
85246f0754 | ||
|
|
c5becb0990 | ||
|
|
13d4102dc8 | ||
|
|
8ab73caeb9 | ||
|
|
e6dbb19eff | ||
|
|
2efa593d2b | ||
|
|
dfc73ea307 | ||
|
|
ed6c94b941 | ||
|
|
f8cbf4693b | ||
|
|
7d51741de0 | ||
|
|
7e3f60bbdb | ||
|
|
f81024e3f0 | ||
|
|
097c14ca64 | ||
|
|
b9128a7a0b | ||
|
|
5c86522e28 | ||
|
|
b6ce06b515 | ||
|
|
13bc6d8fee | ||
|
|
c5402d8bd7 | ||
|
|
b53abe93bc | ||
|
|
377cae9fd8 | ||
|
|
fd1edae706 | ||
|
|
2fab54546e | ||
|
|
820230d61d | ||
|
|
af363e975a | ||
|
|
96571df308 | ||
|
|
9868926ec1 | ||
|
|
69ca57e45a | ||
|
|
9157e1faf3 | ||
|
|
87baf0363e | ||
|
|
4c6f242345 | ||
|
|
1c8cf62582 | ||
|
|
52e1a58904 | ||
|
|
b6cef49ee6 | ||
|
|
af94c64f46 | ||
|
|
e251a5afea | ||
|
|
62d162f001 | ||
|
|
8811ebcc33 | ||
|
|
c0529a26c4 | ||
|
|
a28d9bb840 | ||
|
|
06ec89e787 | ||
|
|
a4d733fb9d | ||
|
|
c3d56f736a | ||
|
|
c629d08a9c | ||
|
|
50a41109d0 | ||
|
|
ca5b83f876 | ||
|
|
c29e37a03f | ||
|
|
14ae664941 | ||
|
|
0949bbf167 | ||
|
|
d0a1e90199 | ||
|
|
077f29305d | ||
|
|
4c528afe65 | ||
|
|
57ed383554 | ||
|
|
d53cabd163 | ||
|
|
817d8738a9 | ||
|
|
d6dbbd1472 | ||
|
|
4021801698 | ||
|
|
b38d8e7ae4 | ||
|
|
123a39bd9a | ||
|
|
596dc3fbd7 | ||
|
|
058d58a6f3 | ||
|
|
4caec83c8f | ||
|
|
3ddef59761 | ||
|
|
c7edfd5bd3 | ||
|
|
23d1a21e23 | ||
|
|
e2c54b1fc4 | ||
|
|
622b2795f5 | ||
|
|
c0298248dc | ||
|
|
db07738514 | ||
|
|
700f0aeb35 | ||
|
|
f3fd043255 | ||
|
|
a0e4580f4e | ||
|
|
659b60aac0 | ||
|
|
7131996722 |
+17
-6
@@ -16,7 +16,7 @@
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
#
|
||||
|
||||
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||
CALCOM_LICENSE_KEY=
|
||||
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
# Secret to enable Intercom Identity Verification
|
||||
INTERCOM_SECRET=
|
||||
|
||||
# Posthog Config
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
NEXT_PUBLIC_POSTHOG_HOST=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
@@ -128,7 +133,7 @@ GOOGLE_API_CREDENTIALS=
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
# Sengrid
|
||||
# Sendgrid
|
||||
# Used for email reminders in workflows and internal sync services
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_EMAIL=
|
||||
@@ -137,6 +142,9 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||
# Sentry
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_TRACES_SAMPLE_RATE=
|
||||
|
||||
# Formbricks Experience Management Integration
|
||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
@@ -262,7 +270,7 @@ ORGANIZATIONS_ENABLED=
|
||||
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
|
||||
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
|
||||
|
||||
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||
# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with
|
||||
# existing organizations based on email domain address
|
||||
ORGANIZATIONS_AUTOLINK=
|
||||
|
||||
@@ -334,9 +342,6 @@ AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||
@@ -385,6 +390,9 @@ UNKEY_ROOT_KEY=
|
||||
# https://retellai.com
|
||||
RETELL_AI_KEY=
|
||||
|
||||
# Used for the huddle01 integration
|
||||
HUDDLE01_API_TOKEN=
|
||||
|
||||
# Used to disallow emails as being added as guests on bookings
|
||||
BLACKLISTED_GUEST_EMAILS=
|
||||
|
||||
@@ -400,3 +408,6 @@ NEXT_PUBLIC_WEBSITE_TERMS_URL=
|
||||
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
|
||||
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
|
||||
NEXT_PUBLIC_LOGGER_LEVEL=
|
||||
|
||||
# Used to use Replexica SDK, a tool for real-time AI-powered localization
|
||||
REPLEXICA_API_KEY=
|
||||
@@ -83,11 +83,12 @@ jobs:
|
||||
**/.turbo/**
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: |
|
||||
- name: Log Cache Hit
|
||||
if: steps.cache-api-v1-build.outputs.cache-hit == 'true'
|
||||
run: echo "Cache hit for API v1 build. Skipping build."
|
||||
- name: Run build
|
||||
if: steps.cache-api-v1-build.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for API v1 build. Skipping build."
|
||||
else
|
||||
yarn turbo run build --filter=@calcom/api...
|
||||
fi
|
||||
yarn turbo run build --filter=@calcom/api...
|
||||
shell: bash
|
||||
|
||||
@@ -49,14 +49,15 @@ jobs:
|
||||
path: |
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: |
|
||||
- name: Log Cache Hit
|
||||
if: steps.cache-api-v2-build.outputs.cache-hit == 'true'
|
||||
run: echo "Cache hit for API v2 build. Skipping build."
|
||||
- name: Run build
|
||||
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for API v2 build. Skipping build."
|
||||
else
|
||||
yarn workspace @calcom/api-v2 run generate-schemas
|
||||
rm -rf apps/api/v2/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/api-v2 run build
|
||||
fi
|
||||
yarn workspace @calcom/api-v2 run generate-schemas
|
||||
rm -rf apps/api/v2/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/api-v2 run build
|
||||
shell: bash
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build atoms
|
||||
name: Build Atoms
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
@@ -28,13 +28,14 @@ jobs:
|
||||
path: |
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: |
|
||||
- name: Log Cache Hit
|
||||
if: steps.cache-atoms-build.outputs.cache-hit == 'true'
|
||||
run: echo "Cache hit for Atoms build. Skipping build."
|
||||
- name: Run build
|
||||
if: steps.cache-atoms-build.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for atoms build. Skipping build."
|
||||
else
|
||||
rm -rf packages/platform/atoms/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/atoms run build
|
||||
fi
|
||||
rm -rf packages/platform/atoms/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/atoms run build
|
||||
shell: bash
|
||||
|
||||
@@ -26,16 +26,17 @@ jobs:
|
||||
path: |
|
||||
**/docs/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
# Log cache hit
|
||||
- name: Log Cache Hit
|
||||
if: steps.cache-docs-build.outputs.cache-hit == 'true'
|
||||
run: echo "Cache hit for Docs build. Skipping build."
|
||||
- name: Run build
|
||||
if: steps.cache-docs-build.outputs.cache-hit != 'true'
|
||||
working-directory: docs
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for Docs build. Skipping build."
|
||||
else
|
||||
npm install -g mintlify
|
||||
mintlify dev &
|
||||
sleep 5 # Let it run for 5 seconds
|
||||
kill $!
|
||||
fi
|
||||
npm install -g mintlify
|
||||
mintlify dev &
|
||||
sleep 5 # Let it run for 5 seconds
|
||||
kill $!
|
||||
shell: bash
|
||||
|
||||
@@ -65,7 +65,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
|
||||
@@ -168,7 +168,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
metadata: jsonSchema.nullable(),
|
||||
customInputs: customInputSchema.array().optional(),
|
||||
link: z.string().optional(),
|
||||
hashedLink: z.object({ link: z.string() }).optional().nullable(),
|
||||
hashedLink: z
|
||||
.array(z.object({ link: z.string() }))
|
||||
.optional()
|
||||
.default([]),
|
||||
bookingFields: eventTypeBookingFields.optional().nullable(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
|
||||
eventTypeId,
|
||||
userId,
|
||||
returnDateOverrides: true,
|
||||
bypassBusyCalendarTimes: false,
|
||||
});
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
returnDateOverrides: true,
|
||||
bypassBusyCalendarTimes: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("retrieveScopedAccessibleUsers tests", () => {
|
||||
where: { email: "member2-acme@example.com" },
|
||||
});
|
||||
|
||||
expect(accessibleUserIds.length).toBe(3);
|
||||
expect(accessibleUserIds.length).toBe(11);
|
||||
expect(accessibleUserIds).toContain(memberOneUser.id);
|
||||
expect(accessibleUserIds).toContain(memberTwoUser.id);
|
||||
expect(accessibleUserIds).toContain(adminUser.id);
|
||||
|
||||
@@ -28,3 +28,7 @@ API_KEY_PREFIX=cal_
|
||||
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||
IS_E2E=false
|
||||
DOCS_URL=
|
||||
|
||||
# Axiom logging
|
||||
AXIOM_DATASET=
|
||||
AXIOM_TOKEN=
|
||||
|
||||
@@ -19,16 +19,17 @@
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.51",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -45,7 +46,9 @@
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "6.2.1",
|
||||
"@sentry/nestjs": "^8.37.1",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"@sentry/profiling-node": "^8.37.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -67,7 +70,8 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^15.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.11.0",
|
||||
"winston": "^3.13.0",
|
||||
"winston-transport": "^4.7.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,13 +17,15 @@ import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: true,
|
||||
isGlobal: true,
|
||||
@@ -59,6 +61,10 @@ import { AppController } from "./app.controller";
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: SentryGlobalFilter,
|
||||
},
|
||||
{
|
||||
provide: ThrottlerStorageRedisService,
|
||||
useFactory: (redisService: RedisService) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ValidationError } from "@nestjs/common";
|
||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
import helmet from "helmet";
|
||||
@@ -71,9 +70,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
|
||||
// Exception filters, new filters go at the bottom, keep the order
|
||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
|
||||
}
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new ZodExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
@@ -89,7 +89,7 @@ const DEFAULT_PLATFORM_PARAMS = {
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsExcludeController(true)
|
||||
export class BookingsController_2024_04_15 {
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
private readonly logger = new Logger("BookingsController_2024_04_15");
|
||||
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
@@ -111,8 +111,9 @@ export class BookingsController_2024_04_15 {
|
||||
@Query() queryParams: GetBookingsInput_2024_04_15
|
||||
): Promise<GetBookingsOutput_2024_04_15> {
|
||||
const { filters, cursor, limit } = queryParams;
|
||||
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
|
||||
const bookings = await getAllUserBookings({
|
||||
bookingListingByStatus: [filters.status],
|
||||
bookingListingByStatus: [bookingListingByStatus],
|
||||
skip: cursor ?? 0,
|
||||
take: limit ?? 10,
|
||||
filters,
|
||||
@@ -355,11 +356,19 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const requestId = req.get("X-Request-Id");
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
|
||||
requestId,
|
||||
ownerId: userId,
|
||||
platformBookingLocation,
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
|
||||
clone.body = { ...clone.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
@@ -375,6 +384,14 @@ export class BookingsController_2024_04_15 {
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
const requestId = req.get("X-Request-Id");
|
||||
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
|
||||
requestId,
|
||||
ownerId: userId,
|
||||
platformBookingLocation,
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(clone, {
|
||||
userId,
|
||||
...oAuthParams,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -27,6 +28,7 @@ import { Module } from "@nestjs/common";
|
||||
OutputBookingsService_2024_08_13,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
ApiKeyRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
|
||||
@@ -56,6 +56,17 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithUser(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdWithAttendeesAndUserAndEvent(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.gua
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||
@@ -9,6 +10,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
@@ -46,12 +48,12 @@ import {
|
||||
CreateBookingInputPipe,
|
||||
CreateBookingInput,
|
||||
GetBookingsInput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
CancelBookingInput_2024_08_13,
|
||||
ReassignToUserBookingInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
DeclineBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -66,7 +68,7 @@ import {
|
||||
required: true,
|
||||
})
|
||||
export class BookingsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
private readonly logger = new Logger("BookingsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@@ -227,4 +229,104 @@ export class BookingsController_2024_08_13 {
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reassign")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Automatically reassign booking to a new host" })
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reassign/:userId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Reassign a booking to a specific user" })
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Param("userId") userId: number,
|
||||
@GetUser("id") reassignedById: number,
|
||||
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBookingToUser(
|
||||
bookingUid,
|
||||
userId,
|
||||
reassignedById,
|
||||
body
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/confirm")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/decline")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: DeclineBookingInput_2024_08_13,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerScheduledEmail,
|
||||
AttendeeDeclinedEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(AttendeeDeclinedEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Bookings confirmation", () => {
|
||||
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 = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
const eventTypeSlug = "peer-coding";
|
||||
|
||||
let createdBooking1: Booking;
|
||||
let createdBooking2: Booking;
|
||||
|
||||
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: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.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 be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should confirm a booking", async () => {
|
||||
const status = "PENDING";
|
||||
createdBooking1 = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
|
||||
title: "peer coding",
|
||||
uid: "peer-coding-one",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "via 10, rome, italy",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Bob",
|
||||
email: "bob@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "bob@gmail.com",
|
||||
name: "Bob",
|
||||
locale: "it",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
status,
|
||||
});
|
||||
expect(createdBooking1.status).toEqual("PENDING");
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.status).toEqual("accepted");
|
||||
|
||||
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
|
||||
expect(dbBooking?.status).toEqual("ACCEPTED");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should decline a booking", async () => {
|
||||
const status = "PENDING";
|
||||
createdBooking2 = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
|
||||
title: "peer coding",
|
||||
uid: "peer-coding-two",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "via 10, rome, italy",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Bob",
|
||||
email: "bob@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "bob@gmail.com",
|
||||
name: "Bob",
|
||||
locale: "it",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
status,
|
||||
});
|
||||
expect(createdBooking2.status).toEqual("PENDING");
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.status).toEqual("rejected");
|
||||
|
||||
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
|
||||
expect(dbBooking?.status).toEqual("REJECTED");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { Booking, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Reassign bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
let team: 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;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const teamUserEmail = "orgUser1team1@api.com";
|
||||
const teamUserEmail2 = "orgUser2team1@api.com";
|
||||
let teamUser1: User;
|
||||
let teamUser2: User;
|
||||
|
||||
let teamRoundRobinEventTypeId: number;
|
||||
|
||||
let roundRobinBooking: Booking;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
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);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "team 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
teamUser1 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail,
|
||||
locale: "it",
|
||||
name: "orgUser1team1",
|
||||
});
|
||||
|
||||
teamUser2 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail2,
|
||||
locale: "it",
|
||||
name: "orgUser2team1",
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser1.id}`,
|
||||
username: teamUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser2.id}`,
|
||||
username: teamUserEmail2,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: teamUser1.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: teamUser2.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
|
||||
},
|
||||
title: "Round Robin Event Type",
|
||||
slug: "round-robin-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
});
|
||||
|
||||
teamRoundRobinEventTypeId = team1EventType.id;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team1EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team1EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
roundRobinBooking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser1.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
|
||||
title: "round robin coding lets goo",
|
||||
uid: "round-robin-coding",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamRoundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
location: "via 10, rome, italy",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Bob",
|
||||
email: "bob@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "bob@gmail.com",
|
||||
name: "Bob",
|
||||
locale: "it",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should reassign round robin booking", async () => {
|
||||
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||
expect(booking?.userId).toEqual(teamUser1.id);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
|
||||
expect(data.reassignedTo).toEqual({
|
||||
id: teamUser2.id,
|
||||
name: teamUser2.name,
|
||||
email: teamUser2.email,
|
||||
});
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||
expect(reassigned?.userId).toEqual(teamUser2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reassign round robin booking to a specific user", async () => {
|
||||
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||
expect(booking?.userId).toEqual(teamUser2.id);
|
||||
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: teamRoundRobinEventTypeId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
|
||||
expect(data.reassignedTo).toEqual({
|
||||
id: teamUser1.id,
|
||||
name: teamUser1.name,
|
||||
email: teamUser1.email,
|
||||
});
|
||||
|
||||
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||
expect(reassigned?.userId).toEqual(teamUser1.id);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -642,4 +642,374 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
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-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const seatedTventTypeSlug = "peer-coding-seated";
|
||||
const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated";
|
||||
let seatedEventTypeId: number;
|
||||
let recurringSeatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
||||
|
||||
const emailAttendeeOne = "mr_proper_first@gmail.com";
|
||||
const nameAttendeeOne = "Mr Proper First";
|
||||
const emailAttendeeTwo = "mr_proper_second@gmail.com";
|
||||
const nameAttendeeTwo = "Mr Proper Second";
|
||||
|
||||
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: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "peer coding",
|
||||
slug: seatedTventTypeSlug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
},
|
||||
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: "peer coding recurring",
|
||||
slug: recurringSeatedTventTypeSlug,
|
||||
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();
|
||||
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: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
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: recurringSeatedTventTypeSlug,
|
||||
});
|
||||
expect(firstBooking.attendees.length).toEqual(1);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
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: recurringSeatedTventTypeSlug,
|
||||
});
|
||||
expect(secondBooking.attendees.length).toEqual(1);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: secondSeatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
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: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
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: recurringSeatedTventTypeSlug,
|
||||
});
|
||||
expect(firstBooking.attendees.length).toEqual(2);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: createdRecurringSeatedBooking[0].seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
expect(firstBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
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: recurringSeatedTventTypeSlug,
|
||||
});
|
||||
expect(secondBooking.attendees.length).toEqual(2);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: createdRecurringSeatedBooking[1].seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
expect(secondBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid: secondSeatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("User bookings", () => {
|
||||
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 = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let variableLengthEventType: EventType;
|
||||
const variableLengthEventTypeSlug = "variable-length-event";
|
||||
let normalEventType: EventType;
|
||||
|
||||
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: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
variableLengthEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "variable length event",
|
||||
slug: variableLengthEventTypeSlug,
|
||||
length: 15,
|
||||
metadata: { multipleDuration: [15, 30, 60] },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
normalEventType = await eventTypesRepositoryFixture.create(
|
||||
{ title: "normal event", slug: "normal-event", length: 15 },
|
||||
user.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 be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create bookings", () => {
|
||||
it("should not be able to specify length of booking for non variable length event type", async () => {
|
||||
const lengthInMinutes = 30;
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: normalEventType.id,
|
||||
lengthInMinutes,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create a booking with default length", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: variableLengthEventType.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
};
|
||||
|
||||
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(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
|
||||
);
|
||||
expect(data.duration).toEqual(variableLengthEventType.length);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a booking with specified length that is not default length", async () => {
|
||||
const lengthInMinutes = 30;
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
lengthInMinutes,
|
||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||
eventTypeId: variableLengthEventType.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
};
|
||||
|
||||
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(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
|
||||
expect(data.duration).toEqual(lengthInMinutes);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
export class ReassignBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [{ $ref: getSchemaPath(ReassignBookingOutputData_2024_08_13) }],
|
||||
description:
|
||||
"Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Object)
|
||||
data!: ReassignBookingOutputData_2024_08_13;
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -14,7 +17,10 @@ import {
|
||||
getAllUserBookings,
|
||||
handleInstantMeeting,
|
||||
handleCancelBooking,
|
||||
roundRobinReassignment,
|
||||
roundRobinManualReassignment,
|
||||
handleMarkNoShow,
|
||||
confirmBookingHandler,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
@@ -22,8 +28,8 @@ import {
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
GetBookingsInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CancelBookingInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
ReassignToUserBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
@@ -46,9 +52,12 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly inputService: InputBookingsService_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly billingService: BillingService
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
@@ -107,7 +116,7 @@ export class BookingsService_2024_08_13 {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ id: booking.id || 0, seatUid: booking.seatReferenceUid || "" }))
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,6 +291,20 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
|
||||
if (this.inputService.isCancelSeatedBody(body)) {
|
||||
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
|
||||
|
||||
if (!seat) {
|
||||
throw new BadRequestException(
|
||||
"Invalid seatUid: this seat does not exist or has already been cancelled."
|
||||
);
|
||||
}
|
||||
|
||||
if (seat && bookingUid !== seat.booking.uid) {
|
||||
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
|
||||
}
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
await handleCancelBooking(bookingRequest);
|
||||
return this.getBooking(bookingUid);
|
||||
@@ -342,4 +365,95 @@ export class BookingsService_2024_08_13 {
|
||||
fromReschedule: oldBookingUid,
|
||||
});
|
||||
}
|
||||
|
||||
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
|
||||
await roundRobinReassignment({
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
});
|
||||
|
||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
if (!reassigned) {
|
||||
throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
}
|
||||
|
||||
async reassignBookingToUser(
|
||||
bookingUid: string,
|
||||
newUserId: number,
|
||||
reassignedById: number,
|
||||
body: ReassignToUserBookingInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
|
||||
}
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const reassigned = await roundRobinManualReassignment({
|
||||
bookingId: booking.id,
|
||||
newUserId,
|
||||
orgId: profile?.organizationId || null,
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
});
|
||||
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
}
|
||||
|
||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
user: requestUser,
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
confirmed: true,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
},
|
||||
});
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
}
|
||||
|
||||
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
user: requestUser,
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
confirmed: false,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CancelBookingInput,
|
||||
CancelBookingInput_2024_08_13,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||
|
||||
@@ -91,6 +93,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputCreateBooking(body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
const requestId = request.get("X-Request-Id");
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
@@ -99,6 +102,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = request.body.location || request.body.meetingUrl;
|
||||
this.logger.log(`createBookingRequest_2024_04_15`, {
|
||||
requestId,
|
||||
ownerId: userId,
|
||||
location,
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||
|
||||
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
@@ -115,10 +125,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||
|
||||
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
|
||||
inputBooking.attendee.timeZone
|
||||
);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
const endTime = startTime.plus({ minutes: lengthInMinutes });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
@@ -142,6 +155,25 @@ export class InputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
|
||||
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
|
||||
throw new BadRequestException(
|
||||
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
|
||||
);
|
||||
}
|
||||
if (
|
||||
inputBooking.lengthInMinutes &&
|
||||
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createRecurringBookingRequest(
|
||||
request: Request,
|
||||
body: CreateRecurringBookingInput_2024_08_13
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
ReassignBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -50,6 +51,8 @@ type DatabaseBooking = Booking & {
|
||||
}[];
|
||||
} & { user: { id: number; name: string | null; email: string } | null };
|
||||
|
||||
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
|
||||
|
||||
@Injectable()
|
||||
export class OutputBookingsService_2024_08_13 {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
@@ -230,14 +233,14 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) {
|
||||
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
const databaseBooking =
|
||||
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
|
||||
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
|
||||
}
|
||||
@@ -308,4 +311,17 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
getOutputReassignedBooking(
|
||||
databaseBooking: Pick<BookingWithUser, "uid" | "user">
|
||||
): ReassignBookingOutput_2024_08_13 {
|
||||
return {
|
||||
bookingUid: databaseBooking.uid,
|
||||
reassignedTo: {
|
||||
id: databaseBooking?.user?.id || 0,
|
||||
name: databaseBooking?.user?.name || "unknown",
|
||||
email: databaseBooking?.user?.email || "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+238
-1
@@ -212,6 +212,7 @@ describe("Event types Endpoints", () => {
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
lengthInMinutesOptions: [30, 60, 90],
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
@@ -300,6 +301,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||
@@ -360,6 +362,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
@@ -665,6 +668,7 @@ describe("Event types Endpoints", () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
lengthInMinutesOptions: [15, 30],
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
@@ -733,6 +737,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
expect(updatedEventType.id).toEqual(eventType.id);
|
||||
expect(updatedEventType.title).toEqual(newTitle);
|
||||
expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
@@ -772,6 +777,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions;
|
||||
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
|
||||
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
|
||||
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
|
||||
@@ -819,6 +825,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
|
||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType.ownerId).toEqual(user.id);
|
||||
@@ -857,6 +864,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
@@ -915,7 +923,7 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle legacy event-types booking fields", () => {
|
||||
describe("Handle event-types booking fields", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
@@ -940,6 +948,7 @@ describe("Event types Endpoints", () => {
|
||||
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
|
||||
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -1092,6 +1101,21 @@ describe("Event types Endpoints", () => {
|
||||
defaultLabel: "reason_for_reschedule",
|
||||
defaultPlaceholder: "reschedule_placeholder",
|
||||
},
|
||||
{
|
||||
name: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
hidden: true,
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
type: "default",
|
||||
label: "Default",
|
||||
},
|
||||
],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "phone_number",
|
||||
},
|
||||
],
|
||||
};
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
|
||||
@@ -1207,6 +1231,21 @@ describe("Event types Endpoints", () => {
|
||||
defaultLabel: "reason_for_reschedule",
|
||||
defaultPlaceholder: "reschedule_placeholder",
|
||||
},
|
||||
{
|
||||
name: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
hidden: true,
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
type: "default",
|
||||
label: "Default",
|
||||
},
|
||||
],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "phone_number",
|
||||
},
|
||||
],
|
||||
};
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
|
||||
@@ -1234,6 +1273,204 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return event type with unknown bookingField", async () => {
|
||||
const unknownSystemField = {
|
||||
name: "unknown-whatever",
|
||||
type: "unknown-whatever",
|
||||
label: "your team",
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: true,
|
||||
placeholder: "FC Barcelona",
|
||||
};
|
||||
|
||||
const eventTypeInput = {
|
||||
title: "unknown field event type two",
|
||||
description: "unknown field event type description two",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "unknown-field-type-two",
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [unknownSystemField],
|
||||
};
|
||||
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(fetchedEventType.bookingFields).toEqual([
|
||||
{
|
||||
type: "unknown",
|
||||
slug: "unknown",
|
||||
bookingField: JSON.stringify(unknownSystemField),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle event-types locations", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
const userEmail = "locations-event-types-test-e2e@api.com";
|
||||
const name = "bob-the-locations-builder";
|
||||
const username = name;
|
||||
let user: User;
|
||||
let legacyEventTypeId1: number;
|
||||
let legacyEventTypeId2: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
name,
|
||||
username,
|
||||
});
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should return integration location with link and credentialId", async () => {
|
||||
const eventTypeInput = {
|
||||
title: "event type discord",
|
||||
description: "event type description",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "discord-event-type",
|
||||
locations: [
|
||||
{
|
||||
type: "integrations:discord_video",
|
||||
link: "https://discord.com/users/100",
|
||||
credentialId: 100,
|
||||
},
|
||||
],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [],
|
||||
};
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
legacyEventTypeId1 = legacyEventType.id;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
expect(fetchedEventType.locations).toEqual([
|
||||
{
|
||||
type: "integration",
|
||||
integration: "discord-video",
|
||||
link: eventTypeInput.locations[0].link,
|
||||
credentialId: eventTypeInput.locations[0].credentialId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return unsupported location", async () => {
|
||||
const eventTypeInput = {
|
||||
title: "event type not existing",
|
||||
description: "event type description",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "not-existing-event-type",
|
||||
locations: [
|
||||
{
|
||||
type: "this-type-does-not-exist",
|
||||
},
|
||||
],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [],
|
||||
};
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
legacyEventTypeId1 = legacyEventType.id;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
expect(fetchedEventType.locations).toEqual([
|
||||
{ type: "unknown", location: JSON.stringify(eventTypeInput.locations[0]) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+4
@@ -104,6 +104,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const {
|
||||
lengthInMinutes,
|
||||
lengthInMinutesOptions,
|
||||
locations,
|
||||
bookingFields,
|
||||
bookingLimitsCount,
|
||||
@@ -135,6 +136,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
@@ -152,6 +154,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
||||
const {
|
||||
lengthInMinutes,
|
||||
lengthInMinutesOptions,
|
||||
locations,
|
||||
bookingFields,
|
||||
bookingLimitsCount,
|
||||
@@ -191,6 +194,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
|
||||
+39
-6
@@ -7,8 +7,7 @@ import {
|
||||
transformLocationsInternalToApi,
|
||||
transformBookingFieldsInternalToApi,
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
InternalLocationSchema,
|
||||
SystemField,
|
||||
CustomField,
|
||||
parseBookingLimit,
|
||||
@@ -20,12 +19,16 @@ import {
|
||||
transformEventTypeColorsInternalToApi,
|
||||
parseEventTypeColor,
|
||||
transformSeatsInternalToApi,
|
||||
InternalLocation,
|
||||
BookingFieldSchema,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
BookerLayoutsTransformedSchema,
|
||||
NoticeThresholdTransformedSchema,
|
||||
EventTypeOutput_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
OutputUnknownBookingField_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
type EventTypeRelations = {
|
||||
@@ -118,7 +121,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
const customName = databaseEventType?.eventName ?? undefined;
|
||||
const bookingFields = databaseEventType.bookingFields
|
||||
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
|
||||
? this.transformBookingFields(databaseEventType.bookingFields)
|
||||
: [];
|
||||
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
@@ -150,6 +153,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
id,
|
||||
ownerId,
|
||||
lengthInMinutes: length,
|
||||
lengthInMinutesOptions: metadata.multipleDuration,
|
||||
title,
|
||||
slug,
|
||||
description: description || "",
|
||||
@@ -190,7 +194,20 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
|
||||
|
||||
const knownLocations: InternalLocation[] = [];
|
||||
const unknownLocations: OutputUnknownLocation_2024_06_14[] = [];
|
||||
|
||||
for (const location of locations) {
|
||||
const result = InternalLocationSchema.safeParse(location);
|
||||
if (result.success) {
|
||||
knownLocations.push(result.data);
|
||||
} else {
|
||||
unknownLocations.push({ type: "unknown", location: JSON.stringify(location) });
|
||||
}
|
||||
}
|
||||
|
||||
return [...transformLocationsInternalToApi(knownLocations), ...unknownLocations];
|
||||
}
|
||||
|
||||
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
|
||||
@@ -201,10 +218,26 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
|
||||
transformBookingFields(bookingFields: any) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
return transformBookingFieldsInternalToApi(bookingFields);
|
||||
const knownBookingFields: (SystemField | CustomField)[] = [];
|
||||
const unknownBookingFields: OutputUnknownBookingField_2024_06_14[] = [];
|
||||
|
||||
for (const bookingField of bookingFields) {
|
||||
const result = BookingFieldSchema.safeParse(bookingField);
|
||||
if (result.success) {
|
||||
knownBookingFields.push(result.data);
|
||||
} else {
|
||||
unknownBookingFields.push({
|
||||
type: "unknown",
|
||||
slug: "unknown",
|
||||
bookingField: JSON.stringify(bookingField),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export type Environment = {
|
||||
DATABASE_URL: string;
|
||||
JWT_SECRET: string;
|
||||
SENTRY_DSN: string;
|
||||
SENTRY_TRACES_SAMPLE_RATE?: number;
|
||||
SENTRY_PROFILES_SAMPLE_RATE?: number;
|
||||
LOG_LEVEL: keyof typeof logLevels;
|
||||
REDIS_URL: string;
|
||||
STRIPE_API_KEY: string;
|
||||
@@ -26,6 +28,8 @@ export type Environment = {
|
||||
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT: number;
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
|
||||
AXIOM_DATASET: string;
|
||||
AXIOM_TOKEN: string;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { getEnv } from "@/env";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
if (process.env.SENTRY_DSN) {
|
||||
// Ensure to call this before requiring any other modules!
|
||||
Sentry.init({
|
||||
dsn: getEnv("SENTRY_DSN"),
|
||||
// Add Performance Monitoring by setting tracesSampleRate
|
||||
// We recommend adjusting this value in production
|
||||
// todo: Evaluate? tracesSampleRate: 1.0
|
||||
integrations: [nodeProfilingIntegration()],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { WinstonTransport as AxiomTransport } from "@axiomhq/winston";
|
||||
import type { LoggerOptions } from "winston";
|
||||
import { format, transports } from "winston";
|
||||
import { format, transports as Transports } from "winston";
|
||||
import type Transport from "winston-transport";
|
||||
|
||||
const formattedTimestamp = format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss.SSS",
|
||||
@@ -36,13 +38,25 @@ export const logLevels = {
|
||||
export const loggerConfig = (): LoggerOptions => {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
const transports: Transport[] = [];
|
||||
transports.push(new Transports.Console());
|
||||
|
||||
if (!!process.env.AXIOM_TOKEN && !!process.env.AXIOM_DATASET) {
|
||||
transports.push(
|
||||
new AxiomTransport({
|
||||
token: process.env.AXIOM_TOKEN,
|
||||
dataset: process.env.AXIOM_DATASET,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
levels: logLevels,
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT,
|
||||
transports: [new transports.Console()],
|
||||
exceptionHandlers: [new transports.Console()],
|
||||
rejectionHandlers: [new transports.Console()],
|
||||
transports,
|
||||
exceptionHandlers: transports,
|
||||
rejectionHandlers: transports,
|
||||
defaultMeta: {
|
||||
service: "cal-platform-api",
|
||||
},
|
||||
|
||||
+12
-4
@@ -1,4 +1,5 @@
|
||||
import type { AppConfig } from "@/config/type";
|
||||
import { getEnv } from "@/env";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
@@ -107,13 +108,20 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const outputFile = "./swagger/documentation.json";
|
||||
const swaggerOutputFile = "./swagger/documentation.json";
|
||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
||||
|
||||
if (fs.existsSync(outputFile)) {
|
||||
fs.unlinkSync(outputFile);
|
||||
if (fs.existsSync(swaggerOutputFile)) {
|
||||
fs.unlinkSync(swaggerOutputFile);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
|
||||
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
|
||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
||||
fs.unlinkSync(docsOutputFile);
|
||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
|
||||
@@ -21,19 +21,15 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
const { statusCode } = response;
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Log response in JSON format
|
||||
this.logger.log(
|
||||
JSON.stringify({
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
responseTime,
|
||||
responseBody: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "Outgoing Response",
|
||||
})
|
||||
);
|
||||
this.logger.log("Outgoing Response", {
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
responseTime,
|
||||
responseBody: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,14 @@ export class RequestIdMiddleware implements NestMiddleware {
|
||||
req.headers["X-Request-Id"] = requestId;
|
||||
const { method, headers, body: requestBody, baseUrl } = req;
|
||||
|
||||
this.logger.log(
|
||||
JSON.stringify({
|
||||
requestId,
|
||||
method,
|
||||
url: baseUrl,
|
||||
headers,
|
||||
requestBody,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "Incoming Request",
|
||||
})
|
||||
);
|
||||
this.logger.log("Incoming Request", {
|
||||
requestId,
|
||||
method,
|
||||
url: baseUrl,
|
||||
headers,
|
||||
requestBody,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -21,4 +21,15 @@ export class AppsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
type: type,
|
||||
key: key,
|
||||
teamId: teamId,
|
||||
appId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { paymentDataSelect } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class AtomsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getRawPayment(uid: string) {
|
||||
return await this.dbRead.prisma.payment.findFirst({
|
||||
where: { uid },
|
||||
select: paymentDataSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,18 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("payment/:uid")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
|
||||
const data = await this.eventTypesService.getUserPaymentInfo(uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getEnabledAppsFromCredentials,
|
||||
getAppFromSlug,
|
||||
MembershipRole,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/platform-libraries";
|
||||
import type {
|
||||
App,
|
||||
@@ -27,6 +29,7 @@ import type {
|
||||
TDependencyData,
|
||||
CredentialPayload,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
type EnabledAppType = App & {
|
||||
@@ -40,6 +43,7 @@ export class EventTypesAtomService {
|
||||
constructor(
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly atomsRepository: AtomsRepository,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
@@ -274,4 +278,43 @@ export class EventTypesAtomService {
|
||||
);
|
||||
return apps[0];
|
||||
}
|
||||
|
||||
async getUserPaymentInfo(uid: string) {
|
||||
const rawPayment = await this.atomsRepository.getRawPayment(uid);
|
||||
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as Record<string, unknown>,
|
||||
};
|
||||
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
|
||||
const { startTime, endTime, eventType, ...restBooking } = _booking;
|
||||
const booking = {
|
||||
...restBooking,
|
||||
startTime: startTime.toString(),
|
||||
endTime: endTime.toString(),
|
||||
};
|
||||
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
|
||||
if (eventType.users.length === 0 && !!!eventType.team)
|
||||
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
|
||||
const [user] = eventType?.users.length
|
||||
? eventType.users
|
||||
: [{ name: null, theme: null, hideBranding: null, username: null }];
|
||||
const profile = {
|
||||
name: eventType.team?.name || user?.name || null,
|
||||
theme: (!eventType.team?.name && user?.theme) || null,
|
||||
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
|
||||
};
|
||||
return {
|
||||
user,
|
||||
eventType: {
|
||||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
},
|
||||
booking,
|
||||
payment,
|
||||
clientSecret: getClientSecretFromPayment(payment),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ export class BillingProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const stripeSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(billingSubscription.subscriptionId);
|
||||
if (!stripeSubscription?.id) {
|
||||
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
|
||||
teamId,
|
||||
@@ -69,7 +69,7 @@ export class BillingProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
|
||||
await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BillingRepository {
|
||||
private readonly logger = new Logger("BillingRepository");
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
getBillingForTeam = (teamId: number) =>
|
||||
@@ -30,6 +31,35 @@ export class BillingRepository {
|
||||
billingCycleEnd: billingEnd,
|
||||
subscriptionId,
|
||||
plan: plan.toString(),
|
||||
overdue: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
|
||||
try {
|
||||
return this.dbWrite.prisma.platformBilling.update({
|
||||
where: {
|
||||
subscriptionId: subId,
|
||||
customerId: cusId,
|
||||
},
|
||||
data: {
|
||||
overdue,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update billing overdue", {
|
||||
subId,
|
||||
cusId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBilling(id: number) {
|
||||
return this.dbWrite.prisma.platformBilling.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import Stripe from "stripe";
|
||||
import * as request from "supertest";
|
||||
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client";
|
||||
|
||||
describe("Platform Billing Controller (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
const userEmail = "platform-billing-controller-e2e@api.com";
|
||||
let user: UserWithProfile;
|
||||
let billing: PlatformBilling;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
let organization: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||
organization = await organizationsRepositoryFixture.create({ name: "platform billing" });
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
team: { connect: { id: organization.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
billing = await platformBillingRepositoryFixture.create(organization.id);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
webhooks: {
|
||||
constructEventAsync: async () => {
|
||||
return {
|
||||
type: "checkout.session.completed",
|
||||
data: {
|
||||
object: {
|
||||
metadata: {
|
||||
teamId: organization.id,
|
||||
plan: "FREE",
|
||||
},
|
||||
mode: "subscription",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe)
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
expect(billing?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
webhooks: {
|
||||
constructEventAsync: async () => {
|
||||
return {
|
||||
type: "invoice.payment_failed",
|
||||
data: {
|
||||
object: {
|
||||
customer: billing?.customerId,
|
||||
subscription: billing?.subscriptionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe)
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
expect(billing?.overdue).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
webhooks: {
|
||||
constructEventAsync: async () => {
|
||||
return {
|
||||
type: "invoice.payment_succeeded",
|
||||
data: {
|
||||
object: {
|
||||
customer: billing?.customerId,
|
||||
subscription: billing?.subscriptionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe)
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
expect(billing?.overdue).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) should delete subscription", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
webhooks: {
|
||||
constructEventAsync: async () => {
|
||||
return {
|
||||
type: "customer.subscription.deleted",
|
||||
data: {
|
||||
object: {
|
||||
metadata: {
|
||||
teamId: organization.id,
|
||||
plan: "FREE",
|
||||
},
|
||||
id: billing?.subscriptionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe)
|
||||
);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
expect(billing).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,13 +102,26 @@ export class BillingController {
|
||||
@Req() request: Request,
|
||||
@Headers("stripe-signature") stripeSignature: string
|
||||
): Promise<ApiResponse> {
|
||||
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
|
||||
request.body,
|
||||
stripeSignature,
|
||||
this.stripeWhSecret
|
||||
);
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
|
||||
await this.billingService.createOrUpdateStripeSubscription(event);
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
|
||||
@@ -64,7 +64,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
@@ -101,7 +101,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
||||
customer: customerId,
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
|
||||
@@ -131,41 +131,92 @@ export class BillingService implements OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async createOrUpdateStripeSubscription(event: Stripe.Event) {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const subscription = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (!subscription.metadata?.teamId) {
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(subscription.metadata.teamId);
|
||||
const plan = subscription.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
if (subscription.mode === "subscription") {
|
||||
await this.setSubscriptionForTeam(
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const teamId = subscription?.metadata?.teamId;
|
||||
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
|
||||
if (teamId && plan) {
|
||||
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
|
||||
if (currentBilling?.subscriptionId === subscription.id) {
|
||||
await this.billingRepository.deleteBilling(currentBilling.id);
|
||||
this.logger.log(`Stripe Subscription deleted`, {
|
||||
customerId: currentBilling.customerId,
|
||||
subscriptionId: currentBilling.subscriptionId,
|
||||
teamId,
|
||||
subscription.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.mode === "setup") {
|
||||
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
this.logger.log("No platform billing found.");
|
||||
return;
|
||||
}
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return;
|
||||
}
|
||||
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
if (typeof invoice.subscription === "string") {
|
||||
return invoice.subscription;
|
||||
} else if (invoice.subscription && typeof invoice.subscription === "object") {
|
||||
return invoice.subscription.id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
if (typeof invoice.customer === "string") {
|
||||
return invoice.customer;
|
||||
} else if (invoice.customer && typeof invoice.customer === "object") {
|
||||
return invoice.customer.id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentSuccess(event: Stripe.Event) {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
if (subscriptionId && customerId) {
|
||||
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentFailed(event: Stripe.Event) {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
if (subscriptionId && customerId) {
|
||||
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event) {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (!checkoutSession.metadata?.teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
|
||||
const plan = checkoutSession.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkoutSession.mode === "subscription") {
|
||||
await this.setSubscriptionForTeam(
|
||||
teamId,
|
||||
checkoutSession.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
}
|
||||
|
||||
if (checkoutSession.mode === "setup") {
|
||||
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
@@ -175,9 +226,9 @@ export class BillingService implements OnModuleDestroy {
|
||||
throw new NotFoundException("Team plan not found");
|
||||
}
|
||||
|
||||
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
teamWithBilling?.platformBilling?.subscriptionId
|
||||
);
|
||||
const existingUserSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId);
|
||||
const currentLicensedItem = existingUserSubscription.items.data.find(
|
||||
(item) => item.price?.recurring?.usage_type === "licensed"
|
||||
);
|
||||
@@ -193,21 +244,23 @@ export class BillingService implements OnModuleDestroy {
|
||||
throw new NotFoundException("There is no overage item present in the subscription");
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: currentLicensedItem.id,
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
},
|
||||
{
|
||||
id: currentOverageItem.id,
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
clear_usage: false,
|
||||
},
|
||||
],
|
||||
billing_cycle_anchor: "now",
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: currentLicensedItem.id,
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
},
|
||||
{
|
||||
id: currentOverageItem.id,
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
clear_usage: false,
|
||||
},
|
||||
],
|
||||
billing_cycle_anchor: "now",
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
|
||||
await this.setSubscriptionForTeam(
|
||||
teamId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export enum PlatformPlan {
|
||||
FREE = "FREE",
|
||||
STARTER = "STARTER",
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||
|
||||
@@ -12,6 +12,9 @@ export class BookingSeatRepository {
|
||||
where: {
|
||||
referenceUid,
|
||||
},
|
||||
include: {
|
||||
booking: { select: { uid: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export class CredentialsRepository {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
+1
-36
@@ -510,7 +510,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(oAuthClient1).toBeDefined();
|
||||
});
|
||||
|
||||
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
|
||||
it(`should create managed user and update team event-types`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmail,
|
||||
timeZone: userTimeZone,
|
||||
@@ -532,48 +532,13 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
await userHasCorrectEventTypes(responseBody.data.user.id);
|
||||
await teamHasCorrectEventTypes(team1.id);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(5);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): default event types
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function teamHasCorrectEventTypes(teamId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(2);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
expect(collective).toBeTruthy();
|
||||
expect(collective?.hosts).toBeDefined();
|
||||
expect(collective?.hosts?.length).toEqual(1);
|
||||
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+3
-29
@@ -25,9 +25,9 @@ import {
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiOperation as DocsOperation,
|
||||
ApiOkResponse as DocsOkResponse,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
} from "@nestjs/swagger";
|
||||
@@ -50,20 +50,7 @@ export class OAuthFlowController {
|
||||
@Post("/authorize")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(NextAuthGuard)
|
||||
@DocsOperation({
|
||||
summary: "Authorize an OAuth client",
|
||||
description:
|
||||
"Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
|
||||
})
|
||||
@DocsOkResponse({
|
||||
description:
|
||||
"The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`",
|
||||
})
|
||||
@DocsBadRequestResponse({
|
||||
description:
|
||||
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
|
||||
})
|
||||
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
|
||||
@DocsExcludeEndpoint()
|
||||
async authorize(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: OAuthAuthorizeInput,
|
||||
@@ -97,20 +84,7 @@ export class OAuthFlowController {
|
||||
|
||||
@Post("/exchange")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsOperation({
|
||||
summary: "Exchange authorization code for access tokens",
|
||||
description:
|
||||
"Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
|
||||
})
|
||||
@DocsOkResponse({
|
||||
type: KeysResponseDto,
|
||||
description: "Successfully exchanged authorization code for access and refresh tokens.",
|
||||
})
|
||||
@DocsBadRequestResponse({
|
||||
description:
|
||||
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
|
||||
})
|
||||
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
|
||||
@DocsExcludeEndpoint()
|
||||
async exchange(
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("clientId") clientId: string,
|
||||
|
||||
@@ -83,8 +83,6 @@ export class OAuthClientUsersService {
|
||||
user.defaultScheduleId = defaultSchedule.id;
|
||||
}
|
||||
|
||||
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
|
||||
+46
-2
@@ -9,8 +9,9 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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";
|
||||
@@ -26,6 +27,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
@@ -36,6 +38,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
let teamEventType: EventType;
|
||||
let managedEventType: EventType;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
|
||||
@@ -66,6 +70,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -97,6 +102,32 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: orgTeam.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "MANAGED",
|
||||
team: {
|
||||
connect: { id: orgTeam.id },
|
||||
},
|
||||
title: "Managed Event Type",
|
||||
slug: "managed-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: "Non Org Team",
|
||||
isOrganization: false,
|
||||
@@ -264,7 +295,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should create the membership of the org's team", async () => {
|
||||
it("should have created the membership of the org's team and assigned team wide events", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.send({
|
||||
@@ -281,9 +312,22 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
|
||||
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
|
||||
});
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id);
|
||||
expect(managedEventTypes?.length).toEqual(1);
|
||||
expect(teamEventTypes?.length).toEqual(2);
|
||||
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
|
||||
expect(collectiveEvenType).toBeTruthy();
|
||||
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
|
||||
expect(userHost).toBeTruthy();
|
||||
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
}
|
||||
|
||||
it("should fail to create the membership of the org's team for a non org user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
|
||||
+32
-2
@@ -15,6 +15,7 @@ import {
|
||||
OrgTeamMembershipsOutputResponseDto,
|
||||
OrgTeamMembershipOutputResponseDto,
|
||||
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
|
||||
import {
|
||||
Controller,
|
||||
@@ -30,11 +31,13 @@ import {
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
UnprocessableEntityException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -44,8 +47,11 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Teams / Memberships")
|
||||
export class OrganizationsTeamsMembershipsController {
|
||||
private logger = new Logger("OrganizationsTeamsMembershipsController");
|
||||
|
||||
constructor(
|
||||
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
||||
private oganizationsEventTypesService: OrganizationsEventTypesService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@@ -112,6 +118,9 @@ export class OrganizationsTeamsMembershipsController {
|
||||
teamId,
|
||||
membershipId
|
||||
);
|
||||
|
||||
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
@@ -129,15 +138,29 @@ export class OrganizationsTeamsMembershipsController {
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number,
|
||||
@Body() data: UpdateOrgTeamMembershipDto
|
||||
): Promise<OrgTeamMembershipOutputResponseDto> {
|
||||
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
|
||||
const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
|
||||
orgId,
|
||||
teamId,
|
||||
membershipId
|
||||
);
|
||||
const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
|
||||
orgId,
|
||||
teamId,
|
||||
membershipId,
|
||||
data
|
||||
);
|
||||
|
||||
if (!currentMembership.accepted && updatedMembership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +181,13 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
|
||||
if (membership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(user.id, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
|
||||
-21
@@ -469,34 +469,13 @@ describe("Organizations Users Endpoints", () => {
|
||||
|
||||
const userData = body.data;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
userHasCorrectEventTypes(userData.id);
|
||||
createdUser = userData;
|
||||
teamHasCorrectEventTypes(team.id);
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(1);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
}
|
||||
|
||||
async function teamHasCorrectEventTypes(teamId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(2);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
expect(collective).toBeTruthy();
|
||||
expect(collective?.hosts).toBeDefined();
|
||||
expect(collective?.hosts?.length).toEqual(1);
|
||||
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export class OrganizationsRepository {
|
||||
}
|
||||
|
||||
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
|
||||
const { id } = await this.stripeService.stripe.customers.create({
|
||||
const { id } = await this.stripeService.getStripe().customers.create({
|
||||
metadata: {
|
||||
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
|
||||
},
|
||||
|
||||
+22
@@ -70,4 +70,26 @@ export class OrganizationsEventTypesRepository {
|
||||
include: { children: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
|
||||
return this.dbWrite.prisma.eventType.deleteMany({
|
||||
where: {
|
||||
parent: {
|
||||
teamId,
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
|
||||
return this.dbWrite.prisma.host.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -6,13 +6,15 @@ import { DatabaseTeamEventType } from "@/modules/organizations/services/event-ty
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries";
|
||||
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsEventTypesService {
|
||||
private readonly logger = new Logger("OrganizationsEventTypesService");
|
||||
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
@@ -145,4 +147,17 @@ export class OrganizationsEventTypesService {
|
||||
|
||||
return this.eventTypesRepository.deleteEventType(eventTypeId);
|
||||
}
|
||||
|
||||
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
|
||||
try {
|
||||
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
|
||||
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not remove user from all team event-types.", {
|
||||
error: err,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(
|
||||
@@ -69,23 +67,4 @@ export class OrganizationsTeamsService {
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async addUserToTeamEvents(userId: number, organizationId: number) {
|
||||
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
|
||||
|
||||
for (const team of orgTeams) {
|
||||
await updateNewTeamMemberEventTypes(userId, team.id);
|
||||
}
|
||||
}
|
||||
|
||||
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
|
||||
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
|
||||
organizationId,
|
||||
oAuthClientId
|
||||
);
|
||||
|
||||
for (const team of oAuthClientTeams) {
|
||||
await updateNewTeamMemberEventTypes(userId, team.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,6 @@ export class OrganizationsUsersService {
|
||||
updateUserBody
|
||||
);
|
||||
|
||||
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
|
||||
|
||||
// Need to send email to new user to create password
|
||||
await this.emailService.sendSignupToOrganizationEmail({
|
||||
usernameOrEmail,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Req,
|
||||
BadRequestException,
|
||||
Headers,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
@@ -46,7 +47,8 @@ export class StripeController {
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("errorRedir") errorRedir?: string | null
|
||||
@Query("errorRedir") errorRedir?: string | null,
|
||||
@Query("teamId") teamId?: string | null
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
@@ -56,6 +58,7 @@ export class StripeController {
|
||||
fromApp: false,
|
||||
returnTo: !!redir ? redir : origin,
|
||||
accessToken,
|
||||
teamId: Number(teamId) ?? null,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
||||
@@ -99,6 +102,16 @@ export class StripeController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check stripe connection" })
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfStripeAccountConnected(user.id);
|
||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||
}
|
||||
|
||||
@Get("/check/:teamId")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId") teamId: string
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
|
||||
controllers: [StripeController],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { Prisma, Credential } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -17,9 +18,17 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
type IntegrationOAuthCallbackState = {
|
||||
accessToken: string;
|
||||
returnTo: string;
|
||||
onErrorReturnTo: string;
|
||||
fromApp: boolean;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
public stripe: Stripe;
|
||||
private stripe: Stripe;
|
||||
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
|
||||
|
||||
constructor(
|
||||
@@ -27,13 +36,18 @@ export class StripeService {
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly membershipRepository: MembershipsRepository
|
||||
) {
|
||||
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
getStripe() {
|
||||
return this.stripe;
|
||||
}
|
||||
|
||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
||||
const { client_id } = await this.getStripeAppKeys();
|
||||
|
||||
@@ -76,6 +90,7 @@ export class StripeService {
|
||||
|
||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
@@ -92,6 +107,19 @@ export class StripeService {
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
if (oAuthCallbackState.teamId) {
|
||||
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
oAuthCallbackState.teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
@@ -102,18 +130,39 @@ export class StripeService {
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
|
||||
|
||||
if (!stripeCredentials) {
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
|
||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
|
||||
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
|
||||
|
||||
if (!hasAdminAccessToTeam) {
|
||||
throw new BadRequestException("You must be team owner or admin to do this");
|
||||
}
|
||||
}
|
||||
|
||||
async validateStripeCredentials(
|
||||
credentials?: Credential | null
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
if (!credentials) {
|
||||
throw new NotFoundException("Credentials for stripe not found.");
|
||||
}
|
||||
|
||||
if (stripeCredentials.invalid) {
|
||||
if (credentials.invalid) {
|
||||
throw new BadRequestException("Invalid stripe credentials.");
|
||||
}
|
||||
|
||||
const stripeKey = JSON.stringify(stripeCredentials.key);
|
||||
const stripeKey = JSON.stringify(credentials.key);
|
||||
const stripeKeyObject = JSON.parse(stripeKey);
|
||||
|
||||
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
|
||||
|
||||
@@ -82,7 +82,10 @@ export class WebhooksController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all webooks (paginated)" })
|
||||
@ApiOperation({
|
||||
summary: "Get all webooks",
|
||||
description: "Gets a paginated list of webhooks for the authenticated user.",
|
||||
})
|
||||
async getWebhooks(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query() query: SkipTakePagination
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,14 @@ export class PlatformBillingRepositoryFixture {
|
||||
});
|
||||
}
|
||||
|
||||
async get(orgId: number) {
|
||||
return this.prismaWriteClient.platformBilling.findFirst({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSubscriptionForTeam(teamId: number) {
|
||||
// silently try to delete the subscription
|
||||
try {
|
||||
|
||||
@@ -8,9 +8,6 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
|
||||
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
|
||||
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
|
||||
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
|
||||
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
|
||||
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
|
||||
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { GET } from "@calcom/features/tasker/api/cron";
|
||||
|
||||
/**
|
||||
* This runs each minute and we need fresh data each time
|
||||
* @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2
|
||||
**/
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/getServerSideProps";
|
||||
|
||||
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
|
||||
import AppsPage from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
@@ -14,7 +14,6 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: LayoutWrapper,
|
||||
getData: withAppDirSsr(getServerSideProps),
|
||||
Page: AppsPage,
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Feature Flags",
|
||||
() => "Here you can toggle your Cal.com instance features."
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
return (
|
||||
<SettingsHeader title="Feature Flags" description="Here you can toggle your Cal.com instance features.">
|
||||
<FlagListingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import LockedSMSView from "~/settings/admin/locked-sms-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Locked SMS",
|
||||
() => "Lock or unlock SMS sending for users"
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<SettingsHeader title="Locked SMS" description="Lock or unlock SMS sending for users">
|
||||
<LockedSMSView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import LegacyPage from "~/settings/admin/oauth-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "OAuth",
|
||||
() => "Add new OAuth Clients"
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<SettingsHeader title="OAuth" description="Add new OAuth Clients">
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Add new user",
|
||||
() => "Here you can add a new user."
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<SettingsHeader title="Add new user" description="Here you can add a new user">
|
||||
<UsersAddView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -5,7 +5,7 @@ import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-vie
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "OAuth client updation form",
|
||||
(t) => t("oAuth_client_updation_form"),
|
||||
() => ""
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "OAuth client updation form",
|
||||
(t) => t("oAuth_client_updation_form"),
|
||||
() => ""
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export function WithLayout<T extends Record<string, any>>({
|
||||
isBookingPage,
|
||||
requiresLicense,
|
||||
}: WithLayoutParams<T>) {
|
||||
// eslint-disable-next-line react/display-name
|
||||
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("feature_flags"),
|
||||
(t) => t("admin_flags_description")
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
return (
|
||||
<SettingsHeader title={t("feature_flags")} description={t("admin_flags_description")}>
|
||||
<FlagListingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import LockedSMSView from "~/settings/admin/locked-sms-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("lockedSMS"),
|
||||
(t) => t("admin_lockedSMS_description")
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
return (
|
||||
<SettingsHeader title={t("lockedSMS")} description={t("admin_lockedSMS_description")}>
|
||||
<LockedSMSView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import LegacyPage from "~/settings/admin/oauth-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("oAuth"),
|
||||
(t) => t("admin_oAuth_description")
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
return (
|
||||
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+10
-8
@@ -1,8 +1,9 @@
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
const input = orgIdSchema.safeParse(params);
|
||||
if (!input.success) {
|
||||
return await _generateMetadata(
|
||||
() => `Editing organization`,
|
||||
() => "Here you can edit an organization."
|
||||
(t) => t("editing_org"),
|
||||
(t) => t("admin_orgs_edit_description")
|
||||
);
|
||||
}
|
||||
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
|
||||
return await _generateMetadata(
|
||||
() => `Editing organization: ${org.name}`,
|
||||
() => "Here you can edit an organization."
|
||||
(t) => `${t("editing_org")}: ${org.name}`,
|
||||
(t) => t("admin_orgs_edit_description")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,11 +35,12 @@ const Page = async ({ params }: { params: Params }) => {
|
||||
|
||||
try {
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={`Editing organization: ${org.name}`}
|
||||
description="Here you can edit an organization.">
|
||||
title={`${t("editing_org")}: ${org.name}`}
|
||||
description={t("admin_orgs_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<OrgForm org={org} />
|
||||
</LicenseRequired>
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { _generateMetadata } from "app/_utils";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Admin",
|
||||
() => "admin_description"
|
||||
(t) => t("admin"),
|
||||
() => ""
|
||||
);
|
||||
|
||||
const Page = () => <h1>Admin index</h1>;
|
||||
+9
-6
@@ -1,8 +1,9 @@
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
const input = userIdSchema.safeParse(params);
|
||||
if (!input.success) {
|
||||
return await _generateMetadata(
|
||||
() => `Editing user`,
|
||||
() => "Here you can edit a current user."
|
||||
(t) => t("editing_user"),
|
||||
(t) => t("admin_users_edit_description")
|
||||
);
|
||||
}
|
||||
|
||||
const user = await UserRepository.adminFindById(input.data.id);
|
||||
|
||||
return await _generateMetadata(
|
||||
() => `Editing user: ${user.username}`,
|
||||
() => "Here you can edit a current user."
|
||||
(t) => `${t("editing_user")}: ${user.username}`,
|
||||
(t) => t("admin_users_edit_description")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,9 +37,11 @@ const Page = async ({ params }: { params: Params }) => {
|
||||
|
||||
try {
|
||||
const user = await UserRepository.adminFindById(input.data.id);
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
|
||||
return (
|
||||
<SettingsHeader title="Editing user" description="Here you can edit a current user">
|
||||
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<UsersEditView user={user} />
|
||||
</LicenseRequired>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("add_new_user"),
|
||||
(t) => t("admin_users_add_description")
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("add_new_user")} description={t("admin_users_add_description")}>
|
||||
<UsersAddView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+9
-6
@@ -1,20 +1,23 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { _generateMetadata, getFixedT } from "app/_utils";
|
||||
|
||||
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
|
||||
import UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Users",
|
||||
() => "A list of all the users in your account including their name, title, email and role."
|
||||
(t) => t("users"),
|
||||
(t) => t("admin_users_description")
|
||||
);
|
||||
|
||||
const Page = () => {
|
||||
const Page = async () => {
|
||||
const session = await getServerSessionForAppDir();
|
||||
const t = await getFixedT(session?.user.locale || "en");
|
||||
return (
|
||||
<SettingsHeader
|
||||
title="Users"
|
||||
description="A list of all the users in your account including their name, title, email and role."
|
||||
title={t("users")}
|
||||
description={t("admin_users_description")}
|
||||
CTA={
|
||||
<div className="mt-4 space-x-5 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
{/* TODO: Add import users functionality */}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import PlatformBillingUpgrade from "~/settings/platform/billing/billing-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("platform_billing"),
|
||||
(t) => t("manage_billing_description")
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLayout({
|
||||
Page: PlatformBillingUpgrade,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
import { type inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("set_up_your_platform_organization"),
|
||||
(t) => t("platform_organization_description")
|
||||
);
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: LayoutWrapper,
|
||||
Page: LegacyPage,
|
||||
getData: withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps),
|
||||
requiresLicense: true,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("oAuth_client_updation_form"),
|
||||
() => ""
|
||||
);
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: null,
|
||||
Page: EditView,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("webhook_update_form"),
|
||||
() => ""
|
||||
);
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: null,
|
||||
Page: EditWebhooksView,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import CreateNewView from "~/settings/platform/oauth-clients/create-new-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("oAuth_client_creation_form"),
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: null,
|
||||
Page: CreateNewView,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import PlatformView from "~/settings/platform/platform-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("platform"),
|
||||
(t) => t("platform_description")
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLayout({
|
||||
getLayout: null,
|
||||
Page: PlatformView,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("platform")} ${t("plans")}`,
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLayout({
|
||||
Page: PlatformPlansView,
|
||||
});
|
||||
@@ -40,6 +40,8 @@ function PageWrapper(props: AppProps) {
|
||||
pageStatus = "404";
|
||||
} else if (router.pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
} else if (router.pathname === "/403") {
|
||||
pageStatus = "403";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
|
||||
@@ -31,6 +31,8 @@ function PageWrapper(props: PageWrapperProps) {
|
||||
pageStatus = "404";
|
||||
} else if (pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
} else if (pathname === "/403") {
|
||||
pageStatus = "403";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
|
||||
@@ -165,8 +165,8 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
|
||||
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => {
|
||||
if (!item.teams.length) return <ChildAppCard item={item} />;
|
||||
.map((item, i) => {
|
||||
if (!item.teams.length) return <ChildAppCard key={i} item={item} />;
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
@@ -11,7 +11,7 @@ type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -21,7 +21,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
@@ -37,6 +37,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ShellMain>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const parsedBooking = buildParsedBooking(booking);
|
||||
|
||||
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
@@ -113,6 +113,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -131,6 +132,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
@@ -268,6 +270,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (isBookingInPast || isOngoing) {
|
||||
editBookingActions.push({
|
||||
id: "no_show",
|
||||
label: t("mark_as_no_show"),
|
||||
onClick: () => {
|
||||
setIsNoShowDialogOpen(true);
|
||||
},
|
||||
icon: "eye-off" as const,
|
||||
});
|
||||
}
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
@@ -417,12 +430,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
@@ -452,6 +467,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
@@ -983,7 +1006,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end p-2">
|
||||
<div className="flex justify-end p-2 ">
|
||||
<Button
|
||||
data-testid="update-no-show"
|
||||
color="secondary"
|
||||
@@ -999,6 +1022,75 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
attendees,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
bookingUid: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [noShowAttendees, setNoShowAttendees] = useState(
|
||||
attendees.map((attendee) => ({
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
}))
|
||||
);
|
||||
|
||||
const noShowMutation = trpc.viewer.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const newValue = data.attendees[0];
|
||||
setNoShowAttendees((old) =>
|
||||
old.map((attendee) =>
|
||||
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
|
||||
)
|
||||
);
|
||||
showToast(t(data.message), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")}>
|
||||
{noShowAttendees.map((attendee) => (
|
||||
<form
|
||||
key={attendee.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
}}>
|
||||
<div className="bg-muted flex items-center justify-between rounded-md px-4 py-2">
|
||||
<span className="text-emphasis flex flex-col text-sm">
|
||||
{attendee.name}
|
||||
{attendee.email && <span className="text-muted">({attendee.email})</span>}
|
||||
</span>
|
||||
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
<DialogFooter>
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -1035,7 +1127,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end space-x-2 p-2">
|
||||
<div className="flex justify-end space-x-2 p-2 ">
|
||||
<Link href={`mailto:${selectedEmail}`}>
|
||||
<Button
|
||||
color="secondary"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user