Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
42cbd582f2 | ||
|
|
5481248c1a | ||
|
|
bd4a949a7c | ||
|
|
b768cc5b44 | ||
|
|
de80ca2aaa | ||
|
|
4afbe26506 | ||
|
|
f22d585c7d | ||
|
|
1ee4fa0906 | ||
|
|
1fc376c51a | ||
|
|
9382b862e4 | ||
|
|
c2662638a5 | ||
|
|
c2291164fa | ||
|
|
9d01025a1f | ||
|
|
86af6bd626 | ||
|
|
2f4dd328c3 | ||
|
|
6df994389b | ||
|
|
93e93a9c7a | ||
|
|
9087f5307b | ||
|
|
5f6032131b | ||
|
|
4e90b65884 | ||
|
|
b7792d0d0a | ||
|
|
c8cd260112 | ||
|
|
242268d580 | ||
|
|
3999f84eff | ||
|
|
995bf09dde | ||
|
|
59aff41ca3 | ||
|
|
b0e3e4c82f | ||
|
|
3e0d53228e | ||
|
|
df2ef84251 | ||
|
|
dcd07d1cc6 | ||
|
|
5f0cc805a9 | ||
|
|
c5677f1dd9 | ||
|
|
bf9be5915c | ||
|
|
4cf3627d52 | ||
|
|
0424304a50 | ||
|
|
7ab1288fa6 | ||
|
|
c7f4f52efa | ||
|
|
f70e8c5189 | ||
|
|
fcc2e0f2ae | ||
|
|
731f97ebe4 | ||
|
|
474f81f08e | ||
|
|
76a2822753 | ||
|
|
b8d4fccbab | ||
|
|
4f14775a4d | ||
|
|
acbcd59c31 | ||
|
|
05c1ad885b | ||
|
|
d73d0ebb6e | ||
|
|
a3971c127b | ||
|
|
912d3cf5b2 | ||
|
|
73c35ba5a2 | ||
|
|
55f1019908 | ||
|
|
863fc695ec | ||
|
|
d931fb1f9c | ||
|
|
fd054dfb8d | ||
|
|
699e08b2ee | ||
|
|
e1228ca03c | ||
|
|
9ad69c20cf | ||
|
|
9a5efd39ce | ||
|
|
497b0804d3 | ||
|
|
8b50f419e5 | ||
|
|
9208827406 | ||
|
|
8891381d89 | ||
|
|
ab39f0c8b6 | ||
|
|
cd2382e091 | ||
|
|
154c54a2aa | ||
|
|
e949a4c529 | ||
|
|
64b321ac9c | ||
|
|
e90e060739 | ||
|
|
123074f4ba | ||
|
|
8115c86e37 | ||
|
|
92a48b466c | ||
|
|
7a4298dc00 | ||
|
|
3d379c65e8 | ||
|
|
b1d509ba30 |
@@ -5,6 +5,7 @@
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - HUDDLE01
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
@@ -137,3 +138,7 @@ REVERT_PUBLIC_TOKEN=
|
||||
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
|
||||
REVERT_API_URL=https://api.revert.dev/
|
||||
# *********************************************************************************************************
|
||||
|
||||
# - Huddle01
|
||||
# Used for the huddle01 integration
|
||||
HUDDLE01_API_TOKEN=
|
||||
|
||||
+9
-4
@@ -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
|
||||
@@ -128,7 +128,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 +137,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 +265,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,7 +337,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
|
||||
@@ -385,6 +387,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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -108,10 +108,10 @@ To develop locally:
|
||||
nvm use
|
||||
```
|
||||
|
||||
You first might need to install the specific version and then use it:
|
||||
You first might need to install the specific version:
|
||||
|
||||
```sh
|
||||
nvm install && nvm use
|
||||
nvm install
|
||||
```
|
||||
|
||||
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -66,11 +66,8 @@ describe("GET /api/slots", () => {
|
||||
buildMockData();
|
||||
await handler(req, res);
|
||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"slots": {},
|
||||
}
|
||||
`);
|
||||
const response = JSON.parse(res._getData());
|
||||
expect(response.slots).toEqual(expect.objectContaining({}));
|
||||
});
|
||||
test("Returns and event type available slots with passed timeZone", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
@@ -87,11 +84,8 @@ describe("GET /api/slots", () => {
|
||||
buildMockData();
|
||||
await handler(req, res);
|
||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"slots": {},
|
||||
}
|
||||
`);
|
||||
const response = JSON.parse(res._getData());
|
||||
expect(response.slots).toEqual(expect.objectContaining({}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.45",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.56",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -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,13 +356,22 @@ 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;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
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;
|
||||
}
|
||||
|
||||
private async createNextApiRecurringBookingRequest(
|
||||
@@ -369,17 +379,26 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, {
|
||||
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,
|
||||
platformBookingLocation,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
});
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
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 { 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";
|
||||
@@ -16,7 +17,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule],
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule, BookingSeatModule],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
OAuthFlowService,
|
||||
|
||||
@@ -29,6 +29,25 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids: number[]) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: ids,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUid(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
@@ -50,6 +69,23 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdWithAttendeesWithBookingSeatAndUserAndEvent(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
@@ -63,6 +99,31 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
include: {
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecurringByUid(uid: string) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: uid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
|
||||
@@ -34,7 +34,14 @@ import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
CancelBookingInput,
|
||||
CancelBookingInputPipe,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
RescheduleBookingInput,
|
||||
RescheduleBookingInputPipe,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
CreateBookingInput,
|
||||
@@ -59,7 +66,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) {}
|
||||
|
||||
@@ -166,7 +173,8 @@ export class BookingsController_2024_08_13 {
|
||||
})
|
||||
async rescheduleBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: RescheduleBookingInput_2024_08_13,
|
||||
@Body(new RescheduleBookingInputPipe())
|
||||
body: RescheduleBookingInput,
|
||||
@Req() request: Request
|
||||
): Promise<RescheduleBookingOutput_2024_08_13> {
|
||||
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
|
||||
@@ -185,7 +193,8 @@ export class BookingsController_2024_08_13 {
|
||||
async cancelBooking(
|
||||
@Req() request: Request,
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: CancelBookingInput_2024_08_13
|
||||
@Body(new CancelBookingInputPipe())
|
||||
body: CancelBookingInput
|
||||
): Promise<CancelBookingOutput_2024_08_13> {
|
||||
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
|
||||
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
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 { 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 { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
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 {
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Seated 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 = "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_seated@gmail.com";
|
||||
const nameAttendeeOne = "Mr Proper Seated";
|
||||
const emailAttendeeTwo = "mr_proper_friend_seated@gmail.com";
|
||||
const nameAttendeeTwo = "Mr Proper Friend Seated";
|
||||
|
||||
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 an event type with seats for the first time", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
};
|
||||
|
||||
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(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedTventTypeSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.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(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should book an event type with seats for the second time", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "Rust",
|
||||
},
|
||||
};
|
||||
|
||||
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(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedTventTypeSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(2);
|
||||
// note(Lauris): first attendee is from previous test request
|
||||
const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne);
|
||||
expect(firstAttendee).toEqual({
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
timeZone: createdSeatedBooking.attendees[0].timeZone,
|
||||
language: createdSeatedBooking.attendees[0].language,
|
||||
absent: false,
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
|
||||
expect(secondAttendee).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking = 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", async () => {
|
||||
const recurrenceCount = 2;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringSeatedEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "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 get a booking for an event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
const expected = structuredClone(createdSeatedBooking);
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete expected.seatUid;
|
||||
expect(data).toEqual(expected);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a booking for a recurring event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
|
||||
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
||||
const expected = structuredClone(createdRecurringSeatedBooking);
|
||||
for (const booking of expected) {
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete booking.seatUid;
|
||||
}
|
||||
expect(data).toEqual(expected);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
const expected = structuredClone(createdRecurringSeatedBooking[0]);
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete expected.seatUid;
|
||||
expect(data).toEqual(expected);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all seated bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?sortCreated=asc")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(3);
|
||||
|
||||
const seatedBooking = responseBody.data[0];
|
||||
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete seatedBookingExpected.seatUid;
|
||||
expect(seatedBooking).toEqual(seatedBookingExpected);
|
||||
|
||||
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
|
||||
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
|
||||
for (const booking of recurringSeatedBookingsExpected) {
|
||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete booking.seatUid;
|
||||
}
|
||||
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reschedule seated booking", async () => {
|
||||
const body: RescheduleSeatedBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedTventTypeSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: attendee?.name,
|
||||
timeZone: attendee?.timeZone,
|
||||
language: attendee?.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: attendee?.name,
|
||||
...attendee?.bookingFieldsResponses,
|
||||
},
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking = data;
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedTventTypeSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
|
||||
return data.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -29,6 +29,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -369,7 +370,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
|
||||
});
|
||||
@@ -384,7 +389,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
|
||||
});
|
||||
@@ -399,7 +408,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
|
||||
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
|
||||
@@ -428,6 +441,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+104
-21
@@ -28,7 +28,11 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
@@ -478,7 +482,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -492,7 +500,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -506,7 +518,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -520,7 +536,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
});
|
||||
});
|
||||
@@ -534,7 +554,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -548,7 +572,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -562,7 +590,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -576,7 +608,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -590,7 +626,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -604,7 +644,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -618,7 +662,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -632,7 +680,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -646,7 +698,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -660,9 +716,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
console.log("asap data", JSON.stringify(data, null, 2));
|
||||
expect(data[0].start).toEqual(createdBooking.start);
|
||||
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
});
|
||||
@@ -677,7 +736,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
expect(data[1].start).toEqual(createdBooking.start);
|
||||
@@ -693,7 +756,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].start).toEqual(createdBooking.start);
|
||||
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
@@ -709,7 +776,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
expect(data[1].start).toEqual(createdBooking.start);
|
||||
@@ -725,7 +796,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].start).toEqual(createdBooking.start);
|
||||
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
@@ -741,7 +816,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
|
||||
expect(data[1].start).toEqual(createdBooking.start);
|
||||
@@ -765,6 +844,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
|
||||
expect(data.start).toEqual(body.start);
|
||||
@@ -793,6 +874,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.status).toEqual("cancelled");
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class CancelBookingOutput_2024_08_13 {
|
||||
@@ -17,10 +22,19 @@ export class CancelBookingOutput_2024_08_13 {
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||
{ $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects",
|
||||
})
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
|
||||
data!:
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13[]
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
| GetRecurringSeatedBookingOutput_2024_08_13
|
||||
| GetRecurringSeatedBookingOutput_2024_08_13[];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class CreateBookingOutput_2024_08_13 {
|
||||
@@ -21,5 +26,9 @@ export class CreateBookingOutput_2024_08_13 {
|
||||
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
|
||||
})
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
|
||||
data!:
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13[]
|
||||
| CreateSeatedBookingOutput_2024_08_13
|
||||
| CreateRecurringSeatedBookingOutput_2024_08_13[];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class RescheduleBookingOutput_2024_08_13 {
|
||||
@@ -15,11 +22,17 @@ export class RescheduleBookingOutput_2024_08_13 {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
|
||||
data!:
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| CreateSeatedBookingOutput_2024_08_13
|
||||
| CreateRecurringSeatedBookingOutput_2024_08_13;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
GetBookingsInput_2024_08_13,
|
||||
@@ -27,6 +26,10 @@ import {
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
RescheduleBookingInput,
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@@ -54,9 +57,19 @@ export class BookingsService_2024_08_13 {
|
||||
return await this.createInstantBooking(request, body);
|
||||
}
|
||||
|
||||
if (await this.isRecurring(body)) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
const isRecurring = !!eventType?.recurringEvent;
|
||||
const isSeated = !!eventType?.seatsPerTimeSlot;
|
||||
|
||||
if (isRecurring && isSeated) {
|
||||
return await this.createRecurringSeatedBooking(request, body);
|
||||
}
|
||||
if (isRecurring && !isSeated) {
|
||||
return await this.createRecurringBooking(request, body);
|
||||
}
|
||||
if (isSeated) {
|
||||
return await this.createSeatedBooking(request, body);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body);
|
||||
} catch (error) {
|
||||
@@ -83,47 +96,71 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async isRecurring(body: CreateBookingInput) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
return !!eventType?.recurringEvent;
|
||||
}
|
||||
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
const uid = bookings[0].recurringEventId;
|
||||
if (!uid) {
|
||||
throw new Error("Recurring booking was not created");
|
||||
}
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||
return this.outputService.getOutputRecurringBookings(recurringBooking);
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_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 || "" }))
|
||||
);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
|
||||
if (!booking.id) {
|
||||
throw new Error("Booking was not created");
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(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`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
||||
booking.uid
|
||||
);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||
}
|
||||
|
||||
async getBooking(uid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid);
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||
|
||||
if (booking) {
|
||||
const isRecurring = !!booking.recurringEventId;
|
||||
if (isRecurring) {
|
||||
const isSeated = !!booking.eventType?.seatsPerTimeSlot;
|
||||
|
||||
if (isRecurring && !isSeated) {
|
||||
return this.outputService.getOutputRecurringBooking(booking);
|
||||
}
|
||||
if (isRecurring && isSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBooking(booking);
|
||||
}
|
||||
if (isSeated) {
|
||||
return this.outputService.getOutputSeatedBooking(booking);
|
||||
}
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
|
||||
@@ -131,8 +168,13 @@ export class BookingsService_2024_08_13 {
|
||||
if (!recurringBooking.length) {
|
||||
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
|
||||
}
|
||||
const ids = recurringBooking.map((booking) => booking.id);
|
||||
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
|
||||
if (isRecurringSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBookings(ids);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputRecurringBookings(recurringBooking);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||
@@ -151,12 +193,17 @@ export class BookingsService_2024_08_13 {
|
||||
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
|
||||
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
|
||||
const ids = fetchedBookings.bookings.map((booking) => booking.id);
|
||||
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids);
|
||||
const bookings = await this.bookingsRepository.getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids);
|
||||
|
||||
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
|
||||
const orderedBookings = ids.map((id) => bookingMap.get(id));
|
||||
|
||||
const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = [];
|
||||
const formattedBookings: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
| GetRecurringSeatedBookingOutput_2024_08_13
|
||||
)[] = [];
|
||||
for (const booking of orderedBookings) {
|
||||
if (!booking) {
|
||||
continue;
|
||||
@@ -172,8 +219,13 @@ export class BookingsService_2024_08_13 {
|
||||
};
|
||||
|
||||
const isRecurring = !!formatted.recurringEventId;
|
||||
if (isRecurring) {
|
||||
const isSeated = !!formatted.eventType?.seatsPerTimeSlot;
|
||||
if (isRecurring && !isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
|
||||
} else if (isRecurring && isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
||||
} else if (isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
|
||||
} else {
|
||||
formattedBookings.push(this.outputService.getOutputBooking(formatted));
|
||||
}
|
||||
@@ -182,7 +234,7 @@ export class BookingsService_2024_08_13 {
|
||||
return formattedBookings;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) {
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
try {
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
request,
|
||||
@@ -190,18 +242,34 @@ export class BookingsService_2024_08_13 {
|
||||
body
|
||||
);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
if (!booking.id) {
|
||||
throw new Error("Booking was not created");
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
const databaseBooking =
|
||||
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`);
|
||||
}
|
||||
|
||||
if (databaseBooking.recurringEventId) {
|
||||
const isRecurring = !!databaseBooking.recurringEventId;
|
||||
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||
|
||||
if (isRecurring && !isSeated) {
|
||||
return this.outputService.getOutputRecurringBooking(databaseBooking);
|
||||
}
|
||||
if (isRecurring && isSeated) {
|
||||
return this.outputService.getOutputCreateRecurringSeatedBooking(
|
||||
databaseBooking,
|
||||
booking?.seatReferenceUid || ""
|
||||
);
|
||||
}
|
||||
if (isSeated) {
|
||||
return this.outputService.getOutputCreateSeatedBooking(
|
||||
databaseBooking,
|
||||
booking.seatReferenceUid || ""
|
||||
);
|
||||
}
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -213,7 +281,7 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) {
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
await handleCancelBooking(bookingRequest);
|
||||
return this.getBooking(bookingUid);
|
||||
@@ -249,7 +317,7 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async billBooking(booking: CreatedBooking) {
|
||||
const hostId = booking.hosts[0].id;
|
||||
const hostId = booking.hosts?.[0]?.id;
|
||||
if (!hostId) {
|
||||
this.logger.error(`Booking with uid=${booking.uid} has no host`);
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingResponsesSchema,
|
||||
} 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 { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
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 { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
@@ -16,13 +20,17 @@ import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import {
|
||||
CancelBookingInput,
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
GetBookingsInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
RescheduleBookingInput,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||
@@ -73,7 +81,8 @@ export class InputBookingsService_2024_08_13 {
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeyRepository
|
||||
private readonly apiKeyRepository: ApiKeyRepository,
|
||||
private readonly bookingSeatRepository: BookingSeatRepository
|
||||
) {}
|
||||
|
||||
async createBookingRequest(
|
||||
@@ -82,6 +91,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;
|
||||
@@ -90,6 +100,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 };
|
||||
@@ -239,9 +256,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
async createRescheduleBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: RescheduleBookingInput_2024_08_13
|
||||
body: RescheduleBookingInput
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
@@ -258,6 +277,60 @@ export class InputBookingsService_2024_08_13 {
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
async transformInputRescheduleSeatedBooking(
|
||||
bookingUid: string,
|
||||
inputBooking: RescheduleSeatedBookingInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
// todo create booking seat module, repository and fetch the seat to get info
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
if (!booking.eventTypeId) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
|
||||
}
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const seat = await this.bookingSeatRepository.getByReferenceUid(inputBooking.seatUid);
|
||||
if (!seat) {
|
||||
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
|
||||
}
|
||||
|
||||
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
|
||||
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
|
||||
|
||||
if (!attendee) {
|
||||
throw new NotFoundException(
|
||||
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} and seatUid=${inputBooking.seatUid} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
eventTypeId: eventType.id,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: booking.metadata || {},
|
||||
metadata: {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: [],
|
||||
responses: { ...bookingResponses },
|
||||
rescheduleUid: inputBooking.seatUid,
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
@@ -371,9 +444,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
async createCancelBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: CancelBookingInput_2024_08_13
|
||||
body: CancelBookingInput
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body);
|
||||
const bodyTransformed = this.isCancelSeatedBody(body)
|
||||
? await this.transformInputCancelSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputCancelBooking(bookingUid, body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
@@ -389,6 +464,10 @@ export class InputBookingsService_2024_08_13 {
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
@@ -410,6 +489,31 @@ export class InputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputCancelSeatedBooking(
|
||||
bookingUid: string,
|
||||
inputBooking: CancelSeatedBookingInput_2024_08_13
|
||||
) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
|
||||
bookingUid
|
||||
);
|
||||
|
||||
if (recurringBooking.length) {
|
||||
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
|
||||
allRemainingBookings = true;
|
||||
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
|
||||
uid = recurringBooking[0].uid;
|
||||
}
|
||||
|
||||
return {
|
||||
uid,
|
||||
cancellationReason: "",
|
||||
allRemainingBookings,
|
||||
seatReferenceUid: inputBooking.seatUid,
|
||||
};
|
||||
}
|
||||
|
||||
transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) {
|
||||
return {
|
||||
noShowHost: inputBooking.host,
|
||||
|
||||
@@ -4,15 +4,34 @@ import { plainToClass } from "class-transformer";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
rescheduledReason: z.string().optional(),
|
||||
rescheduleReason: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const seatedBookingResponsesSchema = z
|
||||
.object({
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -27,6 +46,7 @@ type DatabaseBooking = Booking & {
|
||||
timeZone: string;
|
||||
locale: string | null;
|
||||
noShow: boolean | null;
|
||||
bookingSeat?: BookingSeat | null;
|
||||
}[];
|
||||
} & { user: { id: number; name: string | null; email: string } | null };
|
||||
|
||||
@@ -72,17 +92,18 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
// note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually
|
||||
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
|
||||
async getOutputRecurringBookings(bookingsIds: number[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of databaseBookings) {
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
for (const bookingId of bookingsIds) {
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(bookingId);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
throw new Error(`Booking with id=${bookingId} was not found in the database`);
|
||||
}
|
||||
|
||||
transformed.push(this.getOutputRecurringBooking(databaseBooking));
|
||||
@@ -132,4 +153,159 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
}
|
||||
|
||||
getOutputCreateSeatedBooking(
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
): CreateSeatedBookingOutput_2024_08_13 {
|
||||
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
|
||||
return { ...getSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: [],
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
return attendeeParsed;
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getOutputRecurringSeatedBookings(bookingsIds: number[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const bookingId of bookingsIds) {
|
||||
const databaseBooking =
|
||||
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(bookingId);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${bookingId} was not found in the database`);
|
||||
}
|
||||
|
||||
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking));
|
||||
}
|
||||
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
const databaseBooking =
|
||||
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
|
||||
}
|
||||
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
getOutputCreateRecurringSeatedBooking(
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
): CreateRecurringSeatedBookingOutput_2024_08_13 {
|
||||
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking);
|
||||
return { ...getRecurringSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: [],
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
recurringBookingUid: databaseBooking.recurringEventId,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
|
||||
strategy: "excludeAll",
|
||||
});
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
return attendeeParsed;
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IntegrationLocation } from "@calcom/lib";
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
@@ -7,7 +7,7 @@ type BaseEventType = {
|
||||
};
|
||||
|
||||
type EventTypeWithLocation = BaseEventType & {
|
||||
locations: IntegrationLocation[];
|
||||
locations: OrganizerIntegrationLocation[];
|
||||
};
|
||||
|
||||
const thirtyMinutes: BaseEventType = {
|
||||
|
||||
+289
-5
@@ -31,6 +31,7 @@ import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
NameFieldInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
@@ -199,6 +200,13 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const nameBookingField: NameFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
label: "Your name sir / madam",
|
||||
placeholder: "john doe",
|
||||
disableOnPrefill: false,
|
||||
};
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
@@ -209,8 +217,18 @@ describe("Event types Endpoints", () => {
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
{
|
||||
type: "attendeePhone",
|
||||
},
|
||||
{
|
||||
type: "attendeeAddress",
|
||||
},
|
||||
{
|
||||
type: "attendeeDefined",
|
||||
},
|
||||
],
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language you want to learn",
|
||||
@@ -218,6 +236,7 @@ describe("Event types Endpoints", () => {
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
disableOnPrefill: true,
|
||||
},
|
||||
],
|
||||
scheduleId: firstSchedule.id,
|
||||
@@ -304,12 +323,16 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(createdEventType.color).toEqual(body.color);
|
||||
|
||||
const responseBookingFields = body.bookingFields || [];
|
||||
const requestBookingFields = body.bookingFields || [];
|
||||
const expectedBookingFields = [
|
||||
{ isDefault: true, required: true, slug: "name", type: "name" },
|
||||
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
// note(Lauris): location booking field is added if multiple locations are passed
|
||||
{ isDefault: true, required: false, slug: "location", type: "radioInput" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
...responseBookingFields.map((field) => ({ isDefault: false, ...field })),
|
||||
...requestBookingFields
|
||||
.filter((field) => field.type !== "name" && field.type !== "email")
|
||||
.map((field) => ({ isDefault: false, ...field })),
|
||||
];
|
||||
|
||||
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
|
||||
@@ -630,11 +653,30 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should update event type", async () => {
|
||||
const nameBookingField: NameFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
label: "Your name sir / madam",
|
||||
placeholder: "john doe",
|
||||
disableOnPrefill: true,
|
||||
};
|
||||
|
||||
const newTitle = "Coding class in Italian!";
|
||||
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language you want to learn",
|
||||
slug: "select-language",
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
],
|
||||
bookingLimitsCount: {
|
||||
day: 4,
|
||||
week: 10,
|
||||
@@ -694,7 +736,19 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
|
||||
const requestBookingFields = body.bookingFields || [];
|
||||
const expectedBookingFields = [
|
||||
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
...requestBookingFields
|
||||
.filter((field) => field.type !== "name" && field.type !== "email")
|
||||
.map((field) => ({ isDefault: false, ...field })),
|
||||
];
|
||||
|
||||
expect(updatedEventType.bookingFields).toEqual(expectedBookingFields);
|
||||
|
||||
expect(updatedEventType.ownerId).toEqual(user.id);
|
||||
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
|
||||
expect(updatedEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
|
||||
@@ -732,6 +786,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
eventType.bookingFields = updatedEventType.bookingFields;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -860,7 +915,7 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle legacy event-types booking fields", () => {
|
||||
describe("Handle event-types booking fields", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
@@ -885,6 +940,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 () => {
|
||||
@@ -1037,6 +1093,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);
|
||||
@@ -1152,6 +1223,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);
|
||||
@@ -1179,6 +1265,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);
|
||||
|
||||
+16
-3
@@ -20,6 +20,8 @@ import {
|
||||
transformEventColorsApiToInternal,
|
||||
validateCustomEventName,
|
||||
transformSeatsApiToInternal,
|
||||
SystemField,
|
||||
CustomField,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
@@ -211,16 +213,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
|
||||
hasMultipleLocations: boolean
|
||||
) {
|
||||
const defaultFieldsBefore = [systemBeforeFieldName, systemBeforeFieldEmail];
|
||||
const customFields: (SystemField | CustomField)[] = inputBookingFields
|
||||
? transformBookingFieldsApiToInternal(inputBookingFields)
|
||||
: [];
|
||||
const customFieldsWithoutNameEmail = customFields.filter(
|
||||
(field) => field.type !== "name" && field.type !== "email"
|
||||
);
|
||||
const customNameField = customFields?.find((field) => field.type === "name");
|
||||
const customEmailField = customFields?.find((field) => field.type === "email");
|
||||
|
||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||
customNameField || systemBeforeFieldName,
|
||||
customEmailField || systemBeforeFieldEmail,
|
||||
];
|
||||
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
|
||||
if (hasMultipleLocations) {
|
||||
defaultFieldsBefore.push(systemBeforeFieldLocation);
|
||||
}
|
||||
|
||||
const customFields = transformBookingFieldsApiToInternal(inputBookingFields);
|
||||
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
|
||||
|
||||
return [...defaultFieldsBefore, ...customFields, ...defaultFieldsAfter];
|
||||
return [...defaultFieldsBefore, ...customFieldsWithoutNameEmail, ...defaultFieldsAfter];
|
||||
}
|
||||
|
||||
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
|
||||
|
||||
+38
-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) || {};
|
||||
@@ -190,7 +193,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 +217,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) {
|
||||
|
||||
+34
-5
@@ -121,11 +121,13 @@ describe("Schedules Endpoints", () => {
|
||||
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
|
||||
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
|
||||
|
||||
const outputScheduleAvailability = outputSchedule?.availability[0];
|
||||
expect(outputScheduleAvailability).toBeDefined();
|
||||
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
|
||||
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
|
||||
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
|
||||
if (expectedAvailabilityLength) {
|
||||
const outputScheduleAvailability = outputSchedule?.availability[0];
|
||||
expect(outputScheduleAvailability).toBeDefined();
|
||||
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
|
||||
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
|
||||
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
|
||||
}
|
||||
|
||||
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
|
||||
}
|
||||
@@ -222,6 +224,33 @@ describe("Schedules Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should empty availabilities and overrides", async () => {
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
availability: [],
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createdSchedule,
|
||||
overrides: body.overrides,
|
||||
availability: body.availability,
|
||||
};
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 0);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
|
||||
});
|
||||
|
||||
@@ -119,14 +119,17 @@ export class SchedulesRepository_2024_06_11 {
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
|
||||
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
|
||||
const createAvailabilityStatements: Prisma.AvailabilityCreateManyInput[] = [];
|
||||
const createOverridesStatements: Prisma.AvailabilityCreateManyInput[] = [];
|
||||
|
||||
const deleteAvailabilityStatements: Prisma.AvailabilityWhereInput[] = [];
|
||||
const deleteOverridesStatements: Prisma.AvailabilityWhereInput[] = [];
|
||||
|
||||
const deleteConditions = [];
|
||||
if (availability) {
|
||||
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
|
||||
// but availabilities have "date" field as null, while overrides have it as not null, so delete
|
||||
// condition below results in deleting only rows from Availability table that are availabilities.
|
||||
deleteConditions.push({
|
||||
deleteAvailabilityStatements.push({
|
||||
scheduleId: { equals: scheduleId },
|
||||
date: null,
|
||||
});
|
||||
@@ -136,7 +139,7 @@ export class SchedulesRepository_2024_06_11 {
|
||||
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
|
||||
// but overrides have "date" field as not-null, while availabilities have it as null, so delete
|
||||
// condition below results in deleting only rows from Availability table that are overrides.
|
||||
deleteConditions.push({
|
||||
deleteOverridesStatements.push({
|
||||
scheduleId: { equals: scheduleId },
|
||||
NOT: { date: null },
|
||||
});
|
||||
@@ -144,7 +147,7 @@ export class SchedulesRepository_2024_06_11 {
|
||||
|
||||
if (availability && availability.length > 0) {
|
||||
availability.forEach((availability) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
createAvailabilityStatements.push({
|
||||
days: availability.days,
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
@@ -155,7 +158,7 @@ export class SchedulesRepository_2024_06_11 {
|
||||
|
||||
if (overrides && overrides.length > 0) {
|
||||
overrides.forEach((override) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
createOverridesStatements.push({
|
||||
date: override.date,
|
||||
startTime: override.startTime,
|
||||
endTime: override.endTime,
|
||||
@@ -164,11 +167,21 @@ export class SchedulesRepository_2024_06_11 {
|
||||
});
|
||||
}
|
||||
|
||||
if (availabilitiesAndOverrides.length > 0) {
|
||||
const deleteStatements = [...deleteAvailabilityStatements, ...deleteOverridesStatements];
|
||||
const createStatements = [...createAvailabilityStatements, ...createOverridesStatements];
|
||||
|
||||
if (deleteStatements.length > 0) {
|
||||
updateScheduleData.availability = {
|
||||
deleteMany: deleteConditions,
|
||||
deleteMany: deleteStatements,
|
||||
};
|
||||
}
|
||||
|
||||
if (createStatements.length > 0) {
|
||||
updateScheduleData.availability = {
|
||||
// note(Lauris): keep deleteMany statements
|
||||
...(updateScheduleData.availability || {}),
|
||||
createMany: {
|
||||
data: availabilitiesAndOverrides,
|
||||
data: createStatements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { tap } from "rxjs/operators";
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger("ResponseInterceptor - NestInterceptor");
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const ctx = context.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
@@ -10,7 +13,24 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const { method, url } = request;
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle();
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
const { statusCode } = response;
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
this.logger.log("Outgoing Response", {
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
responseTime,
|
||||
responseBody: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("RequestIdMiddleware - NestMiddleware");
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
req.headers["X-Request-Id"] = uuid();
|
||||
const requestId = uuid();
|
||||
req.headers["X-Request-Id"] = requestId;
|
||||
const { method, headers, body: requestBody, baseUrl } = req;
|
||||
|
||||
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,19 +1,24 @@
|
||||
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";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14],
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
@@ -50,6 +52,38 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("event-types-app/:appSlug")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypeApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("appSlug") appSlug: string,
|
||||
@Query() queryParams: EventTypesAppInput
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const { teamId } = queryParams;
|
||||
|
||||
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user.id, user.name, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
app,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -64,4 +98,20 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateTeamEventType(eventTypeId, body, user, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
export class EventTypesAppInput {
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
teamId?: number;
|
||||
}
|
||||
@@ -1,31 +1,58 @@
|
||||
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";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
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, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
updateEventType,
|
||||
TUpdateEventTypeInputSchema,
|
||||
systemBeforeFieldEmail,
|
||||
getEventTypeById,
|
||||
getEnabledAppsFromCredentials,
|
||||
getAppFromSlug,
|
||||
MembershipRole,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/platform-libraries";
|
||||
import type {
|
||||
App,
|
||||
CredentialDataWithTeamName,
|
||||
LocationOption,
|
||||
TeamQuery,
|
||||
CredentialOwner,
|
||||
TDependencyData,
|
||||
CredentialPayload,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
type EnabledAppType = App & {
|
||||
credential: CredentialDataWithTeamName;
|
||||
credentials: CredentialDataWithTeamName[];
|
||||
locationOption: LocationOption | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
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,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
||||
private readonly teamEventTypeService: OrganizationsEventTypesService
|
||||
) {}
|
||||
|
||||
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, { id: eventTypeId, userId: user.id });
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
@@ -45,12 +72,22 @@ export class EventTypesAtomService {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
if (eventType?.team?.id) {
|
||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||
} else {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
}
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
|
||||
this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
async updateTeamEventType(
|
||||
eventTypeId: number,
|
||||
body: TUpdateEventTypeInputSchema,
|
||||
user: UserWithProfile,
|
||||
teamId: number
|
||||
) {
|
||||
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
@@ -77,4 +114,207 @@ export class EventTypesAtomService {
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
|
||||
await this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
const eventType = await updateEventType({
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async checkCanUpdateTeamEventType(userId: number, eventTypeId: number, teamId: number, scheduleId: number) {
|
||||
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
|
||||
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
|
||||
}
|
||||
|
||||
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
|
||||
const membership = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
eventTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!membership?.team?.eventTypes?.some((item) => item.id === eventTypeId)) {
|
||||
throw new ForbiddenException(
|
||||
`Access denied. Either the team with ID=${teamId} does not own the event type with ID=${eventTypeId}, or your MEMBER role does not have permission to access this resource.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getEventTypesAppIntegration(slug: string, userId: number, userName: string | null, teamId?: number) {
|
||||
let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId);
|
||||
let userTeams: TeamQuery[] = [];
|
||||
if (teamId) {
|
||||
const teamsQuery = await this.organizationsTeamsRepository.getUserTeamsById(userId);
|
||||
// If a team is a part of an org then include those apps
|
||||
// Don't want to iterate over these parent teams
|
||||
const filteredTeams: TeamQuery[] = [];
|
||||
const parentTeams: TeamQuery[] = [];
|
||||
// Only loop and grab parent teams if a teamId was given. If not then all teams will be queried
|
||||
if (teamId) {
|
||||
teamsQuery.forEach((team) => {
|
||||
if (team?.parent) {
|
||||
const { parent, ...filteredTeam } = team;
|
||||
filteredTeams.push(filteredTeam);
|
||||
// Only add parent team if it's not already in teamsQuery
|
||||
if (!teamsQuery.some((t) => t.id === parent.id)) {
|
||||
parentTeams.push(parent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
userTeams = [...teamsQuery, ...parentTeams];
|
||||
const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => {
|
||||
return teamApp.credentials ? teamApp.credentials.flat() : [];
|
||||
});
|
||||
if (teamId) {
|
||||
credentials = teamAppCredentials;
|
||||
} else {
|
||||
credentials = credentials.concat(teamAppCredentials);
|
||||
}
|
||||
}
|
||||
const enabledApps = await getEnabledAppsFromCredentials(credentials, {
|
||||
where: { slug },
|
||||
});
|
||||
const apps = await Promise.all(
|
||||
enabledApps
|
||||
.filter(({ ...app }) => app.slug === slug)
|
||||
.map(
|
||||
async ({
|
||||
credentials: _,
|
||||
credential,
|
||||
key: _2 /* don't leak to frontend */,
|
||||
...app
|
||||
}: EnabledAppType) => {
|
||||
const userCredentialIds = credentials
|
||||
.filter((c) => c.appId === app.slug && !c.teamId)
|
||||
.map((c) => c.id);
|
||||
const invalidCredentialIds = credentials
|
||||
.filter((c) => c.appId === app.slug && c.invalid)
|
||||
.map((c) => c.id);
|
||||
const teams = await Promise.all(
|
||||
credentials
|
||||
.filter((c) => c.appId === app.slug && c.teamId)
|
||||
.map(async (c) => {
|
||||
const team = userTeams.find((team) => team.id === c.teamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
logoUrl: team.logoUrl,
|
||||
credentialId: c.id,
|
||||
isAdmin:
|
||||
team.members[0].role === MembershipRole.ADMIN ||
|
||||
team.members[0].role === MembershipRole.OWNER,
|
||||
};
|
||||
})
|
||||
);
|
||||
const isSetupAlready = credential && app.categories.includes("payment") ? true : undefined;
|
||||
let dependencyData: TDependencyData = [];
|
||||
if (app.dependencies?.length) {
|
||||
dependencyData = app.dependencies.map((dependency) => {
|
||||
const dependencyInstalled = enabledApps.some(
|
||||
(dbAppIterator: EnabledAppType) =>
|
||||
dbAppIterator.credentials.length && dbAppIterator.slug === dependency
|
||||
);
|
||||
// If the app marked as dependency is simply deleted from the codebase,
|
||||
// we can have the situation where App is marked installed in DB but we couldn't get the app.
|
||||
const dependencyName = getAppFromSlug(dependency)?.name;
|
||||
return { name: dependencyName, installed: dependencyInstalled };
|
||||
});
|
||||
}
|
||||
const credentialOwner: CredentialOwner = {
|
||||
name: userName,
|
||||
teamId,
|
||||
};
|
||||
return {
|
||||
...app,
|
||||
...(teams.length && {
|
||||
credentialOwner,
|
||||
}),
|
||||
userCredentialIds,
|
||||
invalidCredentialIds,
|
||||
teams,
|
||||
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
|
||||
isSetupAlready,
|
||||
...(app.dependencies && { dependencyData }),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsUserInOrgTeam implements CanActivate {
|
||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
const userId: string = request.params.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("No user id found in request params.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgTeamUser(
|
||||
Number(orgId),
|
||||
Number(teamId),
|
||||
Number(userId)
|
||||
);
|
||||
|
||||
if (user) {
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [BookingSeatRepository],
|
||||
exports: [BookingSeatRepository],
|
||||
})
|
||||
export class BookingSeatModule {}
|
||||
@@ -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 { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingSeatRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getByReferenceUid(referenceUid: string) {
|
||||
return this.dbRead.prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class CredentialsRepository {
|
||||
@@ -36,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 } });
|
||||
}
|
||||
@@ -65,6 +70,18 @@ export class CredentialsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getAllUserCredentialsById(userId: number) {
|
||||
return await this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserCredentialById(userId: number, credentialId: number, type: string) {
|
||||
return await this.dbRead.prisma.credential.findUnique({
|
||||
where: {
|
||||
|
||||
+24
@@ -248,6 +248,30 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(schedule?.userId).toEqual(userId);
|
||||
}
|
||||
|
||||
it(`should fail /POST using already used managed user email`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmail,
|
||||
timeZone: userTimeZone,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(409);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
const error = responseBody.error;
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.message).toEqual(
|
||||
`User with the provided e-mail already exists. Existing user ID=${postResponseData.user.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`/GET: return list of managed users`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
|
||||
|
||||
+2
@@ -34,4 +34,6 @@ export class CreateManagedUserOutput {
|
||||
@ValidateNested()
|
||||
@Type(() => CreateManagedUserData)
|
||||
data!: CreateManagedUserData;
|
||||
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
@@ -26,9 +26,11 @@ export class OAuthClientUsersService {
|
||||
isPlatformManaged: boolean,
|
||||
organizationId?: number
|
||||
) {
|
||||
const existsWithEmail = await this.managedUserExistsWithEmail(oAuthClientId, body.email);
|
||||
if (existsWithEmail) {
|
||||
throw new BadRequestException("User with the provided e-mail already exists.");
|
||||
const existingUser = await this.getExistingUserByEmail(oAuthClientId, body.email);
|
||||
if (existingUser) {
|
||||
throw new ConflictException(
|
||||
`User with the provided e-mail already exists. Existing user ID=${existingUser.id}`
|
||||
);
|
||||
}
|
||||
|
||||
let user: User;
|
||||
@@ -93,10 +95,9 @@ export class OAuthClientUsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async managedUserExistsWithEmail(oAuthClientId: string, email: string) {
|
||||
async getExistingUserByEmail(oAuthClientId: string, email: string) {
|
||||
const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email);
|
||||
const user = await this.userRepository.findByEmail(oAuthEmail);
|
||||
return !!user;
|
||||
return await this.userRepository.findByEmail(oAuthEmail);
|
||||
}
|
||||
|
||||
async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) {
|
||||
|
||||
+9
-6
@@ -387,15 +387,18 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
1
|
||||
);
|
||||
|
||||
const responseTeamEvent = responseBody.data[0];
|
||||
expect(responseTeamEvent?.teamId).toEqual(team.id);
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
}
|
||||
|
||||
const responseTeammate1Event = responseBody.data[1];
|
||||
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
|
||||
const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teammate1.id);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
const responseTeammate2Event = responseBody.data[2];
|
||||
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
|
||||
const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teammate2.id);
|
||||
expect(responseTeammate2Event).toBeDefined();
|
||||
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
managedEventType = responseTeamEvent;
|
||||
|
||||
+10
-7
@@ -2,6 +2,7 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
|
||||
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -37,7 +38,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
let nonOrgTeam: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: Membership;
|
||||
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
|
||||
|
||||
const userEmail = "org-admin-membership-teams-controller-e2e@api.com";
|
||||
const userEmail2 = "org-member-membership-teams-controller-e2e@api.com";
|
||||
@@ -200,7 +201,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership.id);
|
||||
expect(responseBody.data[1].id).toEqual(membership2.id);
|
||||
@@ -219,7 +220,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[0].userId).toEqual(user2.id);
|
||||
@@ -238,10 +239,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membership.id);
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
expect(responseBody.data.user.email).toEqual(user.email);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,12 +274,13 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,7 +303,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
} satisfies UpdateOrgTeamMembershipDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
@@ -312,7 +315,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
|
||||
+1
@@ -162,6 +162,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
const responseBody: ApiSuccessResponse<OrgMeTeamOutputDto[]> = response.body;
|
||||
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)).toBeDefined();
|
||||
expect(responseBody.data.some((t) => t.accepted)).toBeTruthy();
|
||||
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)?.role).toBe("OWNER");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-6
@@ -67,7 +67,7 @@ export class OrganizationsTeamsController {
|
||||
}
|
||||
|
||||
@Get("/me")
|
||||
@ApiOperation({ summary: "Get team membership for user" })
|
||||
@ApiOperation({ summary: "Get teams membership for user" })
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getMyTeams(
|
||||
@@ -84,13 +84,14 @@ export class OrganizationsTeamsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: teams.map((team) =>
|
||||
plainToClass(
|
||||
data: teams.map((team) => {
|
||||
const me = team.members.find((member) => member.userId === user.id);
|
||||
return plainToClass(
|
||||
OrgMeTeamOutputDto,
|
||||
{ ...team, accepted: team.members.find((member) => member.userId === user.id)?.accepted ?? false },
|
||||
me ? { ...team, role: me.role, accepted: me.accepted } : team,
|
||||
{ strategy: "excludeAll" }
|
||||
)
|
||||
),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Teams / Schedules")
|
||||
export class OrganizationsTeamsSchedulesController {
|
||||
constructor(private schedulesService: SchedulesService_2024_06_11) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrgTeam)
|
||||
@Get("/users/:userId/schedules")
|
||||
@DocsTags("Orgs / Teams / Users / Schedules")
|
||||
@ApiOperation({ summary: "Get schedules of a team member" })
|
||||
async getUserSchedules(
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { 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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.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 { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { Team, Schedule } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Schedules Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
let user2Schedule: Schedule;
|
||||
|
||||
const userEmail = "org-admin-schedules-teams-controller-e2e@api.com";
|
||||
const userEmail2 = "org-member-schedules-teams-controller-e2e@api.com";
|
||||
const nonOrgUserEmail = "non-org-member-schedules-teams-controller-e2e@api.com";
|
||||
|
||||
const invitedUserEmail = "org-member-invited-schedules-teams-controller-e2e@api.com";
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
});
|
||||
|
||||
user2Schedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
name: "User 2 schedule",
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
userToInviteViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: "Org Team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: "Non Org Team",
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userToInviteViaApi.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: nonOrgUser.id } },
|
||||
team: { connect: { id: nonOrgTeam.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user2.id}`,
|
||||
username: userEmail2,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userToInviteViaApi.id}`,
|
||||
username: invitedUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userToInviteViaApi.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all the schedule of the org's team's member", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.find((d) => d.id === user2Schedule.id)?.name).toEqual(user2Schedule.name);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/contro
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
|
||||
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
@@ -100,6 +101,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksService,
|
||||
WebhooksRepository,
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -111,6 +113,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsAttributesController,
|
||||
OrganizationsOptionsAttributesController,
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -103,6 +103,24 @@ export class OrganizationsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeamUser(organizationId: number, teamId: number, userId: number) {
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
profiles: {
|
||||
some: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
some: {
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetchOrgAdminApiStatus(organizationId: number) {
|
||||
return this.dbRead.prisma.organizationSettings.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
@@ -9,6 +10,14 @@ export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly accepted!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: MembershipRole.MEMBER,
|
||||
enum: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER],
|
||||
})
|
||||
@IsEnum([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@Expose()
|
||||
readonly role!: MembershipRole;
|
||||
}
|
||||
|
||||
export class OrgTeamsOutputResponseDto {
|
||||
|
||||
+26
@@ -5,6 +5,27 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class MembershipUserOutputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly avatarUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly email!: string;
|
||||
}
|
||||
|
||||
export class OrgTeamMembershipOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@@ -31,6 +52,11 @@ export class OrgTeamMembershipOutputDto {
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly disableImpersonation?: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => MembershipUserOutputDto)
|
||||
@Expose()
|
||||
user!: MembershipUserOutputDto;
|
||||
}
|
||||
|
||||
export class OrgTeamMembershipsOutputResponseDto {
|
||||
|
||||
+5
-1
@@ -9,13 +9,14 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) {
|
||||
return this.dbRead.prisma.membership.findMany({
|
||||
return await this.dbRead.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
team: {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
@@ -30,6 +31,7 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
});
|
||||
}
|
||||
async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
|
||||
@@ -59,12 +61,14 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.create({
|
||||
data: { ...data, teamId: teamId },
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+52
-1
@@ -4,6 +4,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -97,7 +99,7 @@ export class OrganizationsTeamsRepository {
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: { select: { accepted: true, userId: true } },
|
||||
members: { select: { accepted: true, userId: true, role: true } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
@@ -119,4 +121,53 @@ export class OrganizationsTeamsRepository {
|
||||
|
||||
return team.members.map((member) => member.userId);
|
||||
}
|
||||
|
||||
async getUserTeamsById(userId: number) {
|
||||
const userTeams = await this.dbRead.prisma.team.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userTeams;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +18,14 @@ 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;
|
||||
@@ -27,7 +36,8 @@ 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",
|
||||
@@ -76,6 +86,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 +103,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 +126,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);
|
||||
|
||||
@@ -33,7 +33,8 @@ export class CreateManagedUserInput {
|
||||
@ApiProperty({
|
||||
example: "America/New_York",
|
||||
description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have
|
||||
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.`,
|
||||
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.
|
||||
It will default to Europe/London if not passed.`,
|
||||
})
|
||||
timeZone?: string;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -126,7 +126,7 @@ const EventTypeGroup = ({
|
||||
}: ConfigureStepCardProps & {
|
||||
groupIndex: number;
|
||||
setUpdatedEventTypesStatus: Dispatch<SetStateAction<TUpdatedEventTypesStatus>>;
|
||||
submitRefs: Array<React.RefObject<HTMLButtonElement>>;
|
||||
submitRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>;
|
||||
}) => {
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
@@ -174,7 +174,9 @@ const EventTypeGroup = ({
|
||||
return res;
|
||||
});
|
||||
}}
|
||||
ref={submitRefs[index]}
|
||||
ref={(el) => {
|
||||
submitRefs.current[index] = el;
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -193,17 +195,8 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
keyName: "fieldId",
|
||||
});
|
||||
const eventTypeGroups = watch("eventTypeGroups");
|
||||
const submitRefs = useRef<Array<Array<React.RefObject<HTMLButtonElement>>>>([]);
|
||||
const submitRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
submitRefs.current = eventTypeGroups.reduce(
|
||||
(arr: Array<Array<React.RefObject<HTMLButtonElement>>>, field) => {
|
||||
const res = field.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((_ref) => React.createRef<HTMLButtonElement>());
|
||||
return [...arr, res];
|
||||
},
|
||||
[]
|
||||
);
|
||||
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
|
||||
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState<TUpdatedEventTypesStatus>(
|
||||
eventTypeGroups.reduce((arr: Array<{ id: number; updated: boolean }[]>, field) => {
|
||||
@@ -245,7 +238,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
<EventTypeGroup
|
||||
groupIndex={groupIndex}
|
||||
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
|
||||
submitRefs={submitRefs.current[groupIndex]}
|
||||
submitRefs={submitRefs}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -258,7 +251,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.map((group) => group?.map((ref) => ref.current?.click()));
|
||||
submitRefs.current.forEach((ref) => ref?.click());
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import type { ActionType } from "@calcom/ui";
|
||||
import {
|
||||
Badge,
|
||||
@@ -48,6 +49,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
@@ -65,9 +67,43 @@ type BookingItemProps = BookingItem & {
|
||||
};
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
||||
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
|
||||
type TeamEventBooking = Omit<ParsedBooking, "eventType"> & {
|
||||
eventType: TeamEvent;
|
||||
};
|
||||
type ReroutableBooking = Ensure<TeamEventBooking, "routedFromRoutingFormReponse">;
|
||||
|
||||
function buildParsedBooking(booking: BookingItemProps) {
|
||||
// The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence
|
||||
const bookingEventType = booking.eventType.id
|
||||
? (booking.eventType as Ensure<
|
||||
typeof booking.eventType,
|
||||
// It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here.
|
||||
"id" | "length" | "title" | "slug" | "schedulingType" | "team"
|
||||
>)
|
||||
: null;
|
||||
|
||||
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
|
||||
return {
|
||||
...booking,
|
||||
eventType: bookingEventType,
|
||||
metadata: bookingMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => {
|
||||
// We support only team bookings for now for rerouting
|
||||
// Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now.
|
||||
// Let's not support re-routing for a booking without an event-type for now.
|
||||
// Such a booking has its event-type deleted and there might not be something to reroute to.
|
||||
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
const parsedBooking = buildParsedBooking(booking);
|
||||
|
||||
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
@@ -77,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) => {
|
||||
@@ -107,7 +144,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
|
||||
const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl;
|
||||
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
||||
@@ -191,6 +228,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setIsOpenRescheduleDialog(true);
|
||||
},
|
||||
},
|
||||
...(isBookingReroutable(parsedBooking)
|
||||
? [
|
||||
{
|
||||
id: "reroute",
|
||||
label: t("reroute"),
|
||||
onClick: () => {
|
||||
setRerouteDialogIsOpen(true);
|
||||
},
|
||||
icon: "waypoints" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "change_location",
|
||||
label: t("edit_location"),
|
||||
@@ -220,6 +269,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (isBookingInPast) {
|
||||
editBookingActions.push({
|
||||
id: "no_show",
|
||||
label: t("mark_as_no_show"),
|
||||
onClick: () => {
|
||||
setIsNoShowDialogOpen(true);
|
||||
},
|
||||
icon: "eye-off" as const,
|
||||
});
|
||||
}
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
@@ -275,6 +335,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
@@ -360,6 +421,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -367,12 +429,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}
|
||||
@@ -402,6 +466,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>
|
||||
@@ -620,6 +692,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isBookingReroutable(parsedBooking) && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -722,7 +801,7 @@ const FirstAttendee = ({
|
||||
className=" hover:text-blue-500"
|
||||
href={`mailto:${user.email}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{user.name}
|
||||
{user.name || user.email}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -926,7 +1005,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"
|
||||
@@ -942,6 +1021,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();
|
||||
@@ -978,7 +1126,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"
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Label,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
ConfirmationDialogContent,
|
||||
showToast,
|
||||
Input,
|
||||
Icon,
|
||||
RadioGroup as RadioArea,
|
||||
TextAreaField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type ReassignDialog = {
|
||||
isOpenDialog: boolean;
|
||||
@@ -12,9 +34,76 @@ type ReassignDialog = {
|
||||
bookingId: number;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
reassignType: "round_robin" | "team_member";
|
||||
teamMemberId?: number;
|
||||
reassignReason?: string;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
reassignType: z.enum(["round_robin", "team_member"]),
|
||||
teamMemberId: z.number().optional(),
|
||||
reassignReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || val.length >= 10, {
|
||||
message: "Reassign reason must be at least 10 characters long if provided",
|
||||
}),
|
||||
});
|
||||
|
||||
interface TeamMemberOption {
|
||||
label: string;
|
||||
value: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [animationParentRef] = useAutoAnimate<HTMLFormElement>({
|
||||
duration: 150,
|
||||
easing: "ease-in-out",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const debouncedSearch = useDebounce(searchTerm, 500);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
|
||||
trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
|
||||
{
|
||||
bookingId,
|
||||
exclude: "fixedHosts",
|
||||
limit: 10,
|
||||
searchTerm: debouncedSearch,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page.items) ?? [];
|
||||
}, [data]);
|
||||
|
||||
const teamMemberOptions = useMemo(() => {
|
||||
return allRows.map((member) => ({
|
||||
label: member.name || member.email,
|
||||
value: member.id,
|
||||
status: member.status,
|
||||
})) as TeamMemberOption[];
|
||||
}, [allRows]);
|
||||
|
||||
const { ref: observerRef } = useInViewObserver(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, document.querySelector('[role="dialog"]'));
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
reassignType: "round_robin",
|
||||
},
|
||||
});
|
||||
|
||||
const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -31,26 +120,192 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
|
||||
},
|
||||
});
|
||||
|
||||
const roundRobinManualReassignMutation = trpc.viewer.teams.roundRobinManualReassign.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.bookings.get.invalidate();
|
||||
setIsOpenDialog(false);
|
||||
showToast(t("booking_reassigned"), "success");
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
|
||||
showToast(t("no_available_hosts"), "error");
|
||||
} else {
|
||||
showToast(t(error.message), "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [confirmationModal, setConfirmationModal] = useState<{
|
||||
show: boolean;
|
||||
membersStatus: "unavailable" | "available" | null;
|
||||
}>({
|
||||
show: false,
|
||||
membersStatus: null,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
if (values.reassignType === "round_robin") {
|
||||
roundRobinReassignMutation.mutate({ teamId, bookingId });
|
||||
} else {
|
||||
if (values.teamMemberId) {
|
||||
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
|
||||
if (selectedMember) {
|
||||
setConfirmationModal({
|
||||
show: true,
|
||||
membersStatus: selectedMember.status as "available" | "unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const watchedReassignType = form.watch("reassignType");
|
||||
const watchedTeamMemberId = form.watch("teamMemberId");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpenDialog}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpenDialog(open);
|
||||
}}>
|
||||
<DialogContent title={t("reassign_round_robin_host")} description={t("reassign_to_another_rr_host")}>
|
||||
{/* TODO add team member reassignment*/}
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="rejection-confirm"
|
||||
loading={roundRobinReassignMutation.isPending}
|
||||
onClick={() => {
|
||||
roundRobinReassignMutation.mutate({ teamId, bookingId });
|
||||
}}>
|
||||
{t("reassign")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpenDialog}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpenDialog(open);
|
||||
}}>
|
||||
<DialogContent
|
||||
title={t("reassign_round_robin_host")}
|
||||
description={t("reassign_to_another_rr_host")}
|
||||
enableOverflow>
|
||||
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
|
||||
<RadioArea.Group
|
||||
onValueChange={(val) => {
|
||||
form.setValue("reassignType", val as "team_member" | "round_robin");
|
||||
}}
|
||||
className="mt-1 flex flex-col gap-4">
|
||||
<RadioArea.Item
|
||||
value="round_robin"
|
||||
className="w-full text-sm"
|
||||
classNames={{ container: "w-full" }}>
|
||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item value="team_member" className="text-sm" classNames={{ container: "w-full" }}>
|
||||
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
|
||||
<p>{t("team_member_round_robin_reassign_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
|
||||
{watchedReassignType === "team_member" && (
|
||||
<div className="mb-2">
|
||||
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
|
||||
{teamMemberOptions.map((member) => (
|
||||
<label
|
||||
key={member.value}
|
||||
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
|
||||
role="radio"
|
||||
aria-checked={watchedTeamMemberId === member.value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
form.setValue("teamMemberId", member.value);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
|
||||
watchedTeamMemberId === member.value && "bg-subtle"
|
||||
)}>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="hidden"
|
||||
checked={watchedTeamMemberId === member.value}
|
||||
onChange={() => form.setValue("teamMemberId", member.value)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
"h-3 w-3 flex-shrink-0 rounded-full",
|
||||
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
|
||||
)}
|
||||
/>
|
||||
<span className="text-emphasis w-full text-sm">{member.label}</span>
|
||||
{watchedTeamMemberId === member.value && (
|
||||
<div className="place-self-end pr-2">
|
||||
<Icon name="check" className="text-emphasis h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-default text-center" ref={observerRef}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={isFetchingNextPage}
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => fetchNextPage()}>
|
||||
{hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="rejection-confirm"
|
||||
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
|
||||
{t("reassign")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={confirmationModal?.show}
|
||||
onOpenChange={(open) => setConfirmationModal({ ...confirmationModal, show: open })}>
|
||||
<ConfirmationDialogContent
|
||||
variety={confirmationModal?.membersStatus === "unavailable" ? "warning" : "success"}
|
||||
title={
|
||||
confirmationModal?.membersStatus === "unavailable"
|
||||
? t("confirm_reassign_unavailable")
|
||||
: t("confirm_reassign_available")
|
||||
}
|
||||
confirmBtnText={t("yes_reassign")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() => {
|
||||
const teamMemberId = form.getValues("teamMemberId");
|
||||
if (!teamMemberId) {
|
||||
return;
|
||||
}
|
||||
roundRobinManualReassignMutation.mutate({
|
||||
bookingId,
|
||||
teamMemberId,
|
||||
reassignReason: form.getValues("reassignReason"),
|
||||
});
|
||||
setConfirmationModal({
|
||||
show: false,
|
||||
membersStatus: null,
|
||||
});
|
||||
}}>
|
||||
<p className="mb-4">
|
||||
{confirmationModal?.membersStatus === "unavailable"
|
||||
? t("reassign_unavailable_team_member_description")
|
||||
: t("reassign_available_team_member_description")}
|
||||
</p>
|
||||
<TextAreaField
|
||||
name="reassignReason"
|
||||
label={t("reassign_reason")}
|
||||
onChange={(e) => form.setValue("reassignReason", e.target.value)}
|
||||
required={confirmationModal?.membersStatus === "unavailable"}
|
||||
/>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import FormInputFields, {
|
||||
FormInputFieldsSkeleton,
|
||||
} from "@calcom/app-store/routing-forms/components/FormInputFields";
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
|
||||
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
|
||||
import { getUrlSearchParamsToForwardForReroute } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventType, User, Team, Attendee, Booking as PrismaBooking } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui";
|
||||
import { Button, Tooltip } from "@calcom/ui";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
const enum ReroutingStatusEnum {
|
||||
REROUTING_NOT_INITIATED = "not_initiated",
|
||||
REROUTING_IN_PROGRESS = "in_progress",
|
||||
REROUTING_COMPLETE = "complete",
|
||||
REROUTING_FAILED = "failed",
|
||||
}
|
||||
|
||||
type ResponseWithForm = RouterOutputs["viewer"]["appRoutingForms"]["getResponseWithFormFields"];
|
||||
|
||||
type BookingToReroute = Pick<PrismaBooking, "metadata" | "responses" | "id" | "uid" | "title" | "status"> & {
|
||||
routedFromRoutingFormReponse: {
|
||||
id: number;
|
||||
};
|
||||
startTime: string;
|
||||
metadata: z.infer<typeof bookingMetadataSchema>;
|
||||
attendees: Pick<Attendee, "locale" | "timeZone" | "email" | "name">[];
|
||||
eventType: Pick<EventType, "length" | "schedulingType" | "title" | "id" | "slug"> & {
|
||||
team: Pick<Team, "slug"> | null;
|
||||
};
|
||||
user: Pick<User, "id" | "name" | "email"> | null;
|
||||
};
|
||||
|
||||
type TeamEventTypeBookingToReroute = Omit<BookingToReroute, "eventType"> & {
|
||||
user: NonNullable<BookingToReroute["user"]>;
|
||||
eventType: Omit<BookingToReroute["eventType"], "team"> & {
|
||||
team: Ensure<Pick<Team, "slug">, "slug">;
|
||||
};
|
||||
};
|
||||
|
||||
type RerouteDialogProps = {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: (isOpen: boolean) => void;
|
||||
booking: BookingToReroute;
|
||||
};
|
||||
|
||||
type ReroutingState =
|
||||
| {
|
||||
type: "reschedule_to_same_event_new_tab" | "reschedule_to_different_event_new_tab";
|
||||
/**
|
||||
* UID of the rescheduled booking
|
||||
*/
|
||||
newBooking: string | null;
|
||||
/**
|
||||
* window for the new tab that is opened for rerouting
|
||||
*/
|
||||
reschedulerWindow: Window;
|
||||
error?: Error;
|
||||
}
|
||||
| {
|
||||
type: "same_timeslot";
|
||||
newBooking: string | null;
|
||||
reschedulerWindow: null;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
type TeamMemberMatchingAttributeLogic = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// We can't let user change email identifier field as that would require contact owner information from salesforce which we don't support yet.
|
||||
// Also, this isn't a requirement as well to allow correcting the email
|
||||
const fieldsThatCannotBeCorrected = ["email"];
|
||||
|
||||
function getEventTypeUrlsForTheChosenRoute({
|
||||
chosenRoute,
|
||||
form,
|
||||
reroutingFormResponses,
|
||||
currentResponse,
|
||||
teamMemberIdsMatchingAttributeLogic,
|
||||
booking,
|
||||
searchParams,
|
||||
}: {
|
||||
form: ResponseWithForm["form"];
|
||||
currentResponse: FormResponse;
|
||||
reroutingFormResponses: Record<string, Pick<FormResponse[keyof FormResponse], "value">>;
|
||||
teamMemberIdsMatchingAttributeLogic: number[] | null;
|
||||
chosenRoute: LocalRoute | null;
|
||||
booking: TeamEventTypeBookingToReroute;
|
||||
searchParams: URLSearchParams;
|
||||
}) {
|
||||
if (!chosenRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formFields = form.fields || [];
|
||||
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
|
||||
|
||||
const allURLSearchParams = getUrlSearchParamsToForwardForReroute({
|
||||
formResponse: currentResponse,
|
||||
formResponseId: routedFromRoutingFormReponseId,
|
||||
fields: formFields,
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
|
||||
attributeRoutingConfig: chosenRoute.attributeRoutingConfig ?? null,
|
||||
rescheduleUid: booking.uid,
|
||||
reroutingFormResponses,
|
||||
});
|
||||
|
||||
const eventFullSlug = substituteVariables(chosenRoute.action.value, currentResponse, formFields);
|
||||
const eventBookingAbsoluteUrl = getAbsoluteEventTypeRedirectUrl({
|
||||
form,
|
||||
eventTypeRedirectUrl: eventFullSlug,
|
||||
allURLSearchParams,
|
||||
isEmbed: false,
|
||||
});
|
||||
|
||||
return { eventFullSlug, eventBookingAbsoluteUrl };
|
||||
}
|
||||
|
||||
function rescheduleInNewTab({ url }: { url: string }) {
|
||||
const reschedulerWindow = window.open(url, "_blank");
|
||||
|
||||
if (!reschedulerWindow) {
|
||||
throw new Error("Failed to open new tab");
|
||||
}
|
||||
|
||||
return reschedulerWindow;
|
||||
}
|
||||
|
||||
const getFullSlugForEvent = (
|
||||
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "team" | "slug">
|
||||
) => {
|
||||
return `team/${eventType.team.slug}/${eventType.slug}`;
|
||||
};
|
||||
|
||||
function isBookingTimeslotInPast(booking: BookingToReroute) {
|
||||
return dayjs(booking.startTime).isBefore(dayjs());
|
||||
}
|
||||
|
||||
const useReroutingState = ({ isOpenDialog }: Pick<RerouteDialogProps, "isOpenDialog">) => {
|
||||
const [value, setValue] = useState<ReroutingState | null>(null);
|
||||
const state = value;
|
||||
|
||||
// If dialog is closed but the rerouting window is still open, close it
|
||||
if (!isOpenDialog && state?.reschedulerWindow) {
|
||||
state.reschedulerWindow.close();
|
||||
setValue(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (state?.reschedulerWindow?.closed) {
|
||||
// Ensure that render happens again
|
||||
setValue(null);
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, 1);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [value, setValue]);
|
||||
|
||||
const status = (() => {
|
||||
if (!value) return ReroutingStatusEnum.REROUTING_NOT_INITIATED;
|
||||
if (value.error) return ReroutingStatusEnum.REROUTING_FAILED;
|
||||
if (!!value.newBooking) return ReroutingStatusEnum.REROUTING_COMPLETE;
|
||||
return ReroutingStatusEnum.REROUTING_IN_PROGRESS;
|
||||
})();
|
||||
|
||||
return { value: state, setValue, status };
|
||||
};
|
||||
|
||||
// const useUpdateIsReroutingQueryParam = ({ isOpenDialog }: { isOpenDialog: boolean }) => {
|
||||
// const { setQuery: setIsReroutingQuery } = useRouterQuery("isRerouting");
|
||||
// useEffect(() => {
|
||||
// if (isOpenDialog) {
|
||||
// setIsReroutingQuery("true");
|
||||
// } else {
|
||||
// setIsReroutingQuery(undefined);
|
||||
// }
|
||||
// }, [isOpenDialog]);
|
||||
// };
|
||||
|
||||
const gotoBookingPage = ({
|
||||
booking,
|
||||
router,
|
||||
}: {
|
||||
booking: Pick<PrismaBooking, "uid">;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
}) => {
|
||||
// TODO: Do we need to send other params as well?
|
||||
router.push(`/booking/${booking.uid}?cal.rerouting=true`);
|
||||
};
|
||||
|
||||
const useEventListeners = ({ reroutingState }: { reroutingState: ReturnType<typeof useReroutingState> }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const messageListener = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
if (event.data.type !== "CAL:rescheduleBookingSuccessfulV2") {
|
||||
return;
|
||||
}
|
||||
const calEventData = event.data.data;
|
||||
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
|
||||
if (!calEventData.uid) {
|
||||
console.error("Booking UID is not there");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
const newBookingId = calEventData.uid;
|
||||
reroutingState.setValue((prev) => {
|
||||
if (!prev) return null;
|
||||
return { ...prev, newBooking: newBookingId };
|
||||
});
|
||||
gotoBookingPage({ router, booking: { uid: newBookingId } });
|
||||
reroutingState.value?.reschedulerWindow?.close();
|
||||
},
|
||||
[reroutingState, reroutingState.value?.reschedulerWindow]
|
||||
);
|
||||
|
||||
// Make sure to close the rescheduler window when this tab closes/reloads
|
||||
const beforeUnloadListener = useCallback(() => {
|
||||
reroutingState.value?.reschedulerWindow?.close();
|
||||
reroutingState.setValue(null);
|
||||
}, [reroutingState.value?.reschedulerWindow]);
|
||||
|
||||
// Ensure listeners are added once the component is mounted and removed once the component is unmounted
|
||||
useEffect(() => {
|
||||
window.addEventListener("message", messageListener);
|
||||
window.addEventListener("beforeunload", beforeUnloadListener);
|
||||
return () => {
|
||||
window.removeEventListener("message", messageListener);
|
||||
window.removeEventListener("beforeunload", beforeUnloadListener);
|
||||
};
|
||||
// messageListener and beforeUnloadListener are memoized using useCallback, so the reference remains stable
|
||||
}, [messageListener, beforeUnloadListener]);
|
||||
};
|
||||
|
||||
const NewRoutingManager = ({
|
||||
chosenRoute,
|
||||
booking,
|
||||
form,
|
||||
currentResponse,
|
||||
teamMembersMatchingAttributeLogic,
|
||||
reroutingState,
|
||||
}: {
|
||||
chosenRoute: LocalRoute;
|
||||
booking: TeamEventTypeBookingToReroute;
|
||||
form: ResponseWithForm["form"];
|
||||
currentResponse: FormResponse;
|
||||
teamMembersMatchingAttributeLogic: {
|
||||
isPending: boolean;
|
||||
data: TeamMemberMatchingAttributeLogic[] | null;
|
||||
};
|
||||
reroutingState: ReturnType<typeof useReroutingState>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const teamMemberIdsMatchingAttributeLogic =
|
||||
teamMembersMatchingAttributeLogic?.data
|
||||
?.map((member) => member.id)
|
||||
.filter((id) => {
|
||||
// We don't want to reroute to the same user who booked the booking
|
||||
return id !== booking.user?.id;
|
||||
}) || null;
|
||||
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
|
||||
|
||||
const bookingEventType = booking.eventType;
|
||||
|
||||
// Provide a reason for rescheduling that can be customized by user in the future
|
||||
// const rescheduleReason = "Rerouted";
|
||||
const isRoundRobinScheduling = bookingEventType.schedulingType === SchedulingType.ROUND_ROBIN;
|
||||
const reroutingFormResponses = Object.fromEntries(
|
||||
Object.entries(currentResponse).map(([key, response]) => [key, { value: response.value }])
|
||||
);
|
||||
|
||||
const chosenEventUrls = getEventTypeUrlsForTheChosenRoute({
|
||||
chosenRoute,
|
||||
form,
|
||||
reroutingFormResponses,
|
||||
teamMemberIdsMatchingAttributeLogic,
|
||||
booking,
|
||||
currentResponse,
|
||||
searchParams: new URLSearchParams({
|
||||
// rescheduleReason
|
||||
}),
|
||||
});
|
||||
|
||||
const chosenEventTypeId = chosenRoute.action.eventTypeId;
|
||||
const enableChosenEventTypeQuery = !!chosenEventTypeId;
|
||||
// TODO: Get bare minimum eventType details
|
||||
const { data: chosenEventTypeData, isPending: isChosenEventTypePending } =
|
||||
trpc.viewer.eventTypes.get.useQuery(
|
||||
// enabled prop ensures that the query is not run if the chosenEventTypeId is not there
|
||||
{ id: chosenEventTypeId! },
|
||||
{
|
||||
enabled: enableChosenEventTypeQuery,
|
||||
}
|
||||
);
|
||||
|
||||
// isPending of the query is true even if enabled=false and no request was sent. So, identify if the request is actually in progress
|
||||
const isChosenEventTypeRequestInProgress = enableChosenEventTypeQuery ? isChosenEventTypePending : false;
|
||||
|
||||
const chosenEventType = chosenEventTypeData?.eventType;
|
||||
|
||||
const currentBookingEventFullSlug = getFullSlugForEvent(booking.eventType);
|
||||
const isReroutingToDifferentEvent = currentBookingEventFullSlug !== chosenEventUrls?.eventFullSlug;
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: createBooking,
|
||||
onSuccess: (booking) => {
|
||||
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
|
||||
if (!booking.uid) {
|
||||
console.error("Booking UID is not there");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
const newBookingId = booking.uid;
|
||||
reroutingState.setValue((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
newBooking: newBookingId,
|
||||
};
|
||||
});
|
||||
gotoBookingPage({ router, booking: { uid: newBookingId } });
|
||||
},
|
||||
onError: (err, _, ctx) => {
|
||||
reroutingState.setValue({
|
||||
error: err,
|
||||
newBooking: null,
|
||||
type: "same_timeslot",
|
||||
reschedulerWindow: null,
|
||||
});
|
||||
showToast(
|
||||
"Looks like the timeslot is not available for the new team members. Try rescheduling with different timeslot",
|
||||
"error"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEventListeners({ reroutingState });
|
||||
|
||||
if (!chosenRoute) return null;
|
||||
|
||||
const isReroutingInNewTab =
|
||||
reroutingState.value?.type === "reschedule_to_same_event_new_tab" ||
|
||||
reroutingState.value?.type === "reschedule_to_different_event_new_tab";
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
|
||||
<h2 className="text-emphasis font-medium">{t("new_routing_status")}</h2>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()}
|
||||
{(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) &&
|
||||
reroutingCTAs()}
|
||||
{isReroutingInNewTab && newTabReroutingStatus()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function rescheduleToSameTimeslotOfEvent({
|
||||
eventType: { id: eventTypeId, slug: eventTypeSlug, team: eventTypeTeam, length: eventTypeLength },
|
||||
}: {
|
||||
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "id" | "slug" | "length"> & {
|
||||
team: {
|
||||
slug: string | null;
|
||||
} | null;
|
||||
};
|
||||
}) {
|
||||
if (!chosenRoute) {
|
||||
console.error("Chosen route must be there for rerouting");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
if (!eventTypeTeam?.slug) {
|
||||
console.error("Event type team slug must be there for rerouting");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
const teamSlug = eventTypeTeam.slug;
|
||||
|
||||
if (isBookingTimeslotInPast(booking)) {
|
||||
showToast("You cannot reschedule to a past timeslot", "error");
|
||||
return;
|
||||
}
|
||||
const booker = booking.attendees[0];
|
||||
const getFieldsThatRemainSame = () => {
|
||||
return {
|
||||
responses: booking.responses,
|
||||
timeZone: booker.timeZone,
|
||||
language: booker.locale || "en",
|
||||
metadata: booking.metadata || {},
|
||||
...getBookingSeatFields(),
|
||||
...getHashedLinkFields(),
|
||||
};
|
||||
|
||||
function getBookingSeatFields() {
|
||||
// During rerouting, do all the seats need to be re-routed?
|
||||
// Let's not support re-routing for booking with seats for now.
|
||||
return {
|
||||
// bookingUid: booking.uid,
|
||||
// seatReferenceUid: booking.eventType,
|
||||
};
|
||||
}
|
||||
|
||||
function getHashedLinkFields() {
|
||||
// After booking hashedLink is deleted, so I don't believe we need to return anything here.
|
||||
// Also, Rerouting is for a booking that was routed and routing doesn't involve hashed link even if it is enabled for the event.
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldFromEventTypeThatRemainSame = () => {
|
||||
return {
|
||||
user: teamSlug,
|
||||
eventTypeId: eventTypeId,
|
||||
eventTypeSlug: eventTypeSlug,
|
||||
};
|
||||
};
|
||||
|
||||
const getTimeslotFields = () => {
|
||||
return {
|
||||
// It is possible that EvenType changed its duration, so we would consider that as well.
|
||||
start: dayjs(booking.startTime).format(),
|
||||
end: dayjs(booking.startTime)
|
||||
// Defaults to the default event length in case no custom duration is set.
|
||||
.add(eventTypeLength, "minute")
|
||||
.format(),
|
||||
};
|
||||
};
|
||||
|
||||
const getRoutingFormRelatedFields = () => {
|
||||
return {
|
||||
routedTeamMemberIds: teamMemberIdsMatchingAttributeLogic,
|
||||
routingFormResponseId: routedFromRoutingFormReponseId,
|
||||
skipContactOwner: chosenRoute.attributeRoutingConfig?.skipContactOwner,
|
||||
};
|
||||
};
|
||||
|
||||
const getSalesforceContactOwnerFields = () => {
|
||||
// Email field won't need to be corrected, so we don't need to support it.
|
||||
return {};
|
||||
};
|
||||
|
||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||
createBookingMutation.mutate({
|
||||
rescheduleUid: booking.uid,
|
||||
// rescheduleReason,
|
||||
reroutingFormResponses: reroutingFormResponses,
|
||||
...getTimeslotFields(),
|
||||
...getFieldsThatRemainSame(),
|
||||
...getFieldFromEventTypeThatRemainSame(),
|
||||
...getRoutingFormRelatedFields(),
|
||||
...getSalesforceContactOwnerFields(),
|
||||
});
|
||||
|
||||
reroutingState.setValue({
|
||||
newBooking: null,
|
||||
type: "same_timeslot",
|
||||
reschedulerWindow: null,
|
||||
});
|
||||
}
|
||||
|
||||
function rescheduleToSameTimeslotOfSameEvent() {
|
||||
return rescheduleToSameTimeslotOfEvent({
|
||||
eventType: booking.eventType,
|
||||
});
|
||||
}
|
||||
|
||||
function rescheduleToSameTimeslotOfChosenEvent() {
|
||||
if (!chosenEventTypeId && isReroutingToDifferentEvent) {
|
||||
showToast(
|
||||
"We don't have ID for the new event. Please go to the Routing Form and save it again.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!chosenEventType) {
|
||||
// There are disabled/loading state on button to ensure that this function is not called if chosenEventType is not there
|
||||
console.error("Chosen event type must be there for rerouting");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
return rescheduleToSameTimeslotOfEvent({
|
||||
eventType: chosenEventType,
|
||||
});
|
||||
}
|
||||
|
||||
function rescheduleSameEventInNewTab() {
|
||||
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
|
||||
console.error("URL must be there for opening new tab");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
const reschedulerWindow = rescheduleInNewTab({
|
||||
url: chosenEventUrls.eventBookingAbsoluteUrl,
|
||||
});
|
||||
|
||||
reroutingState.setValue({
|
||||
type: "reschedule_to_same_event_new_tab",
|
||||
reschedulerWindow,
|
||||
newBooking: null,
|
||||
});
|
||||
}
|
||||
|
||||
function rescheduleDifferentEventInNewTab() {
|
||||
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
|
||||
console.error("URL must be there for opening new tab");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
const reschedulerWindow = rescheduleInNewTab({
|
||||
url: chosenEventUrls.eventBookingAbsoluteUrl,
|
||||
});
|
||||
|
||||
console.log("SETTING NEW VALUE", {
|
||||
type: "reschedule_to_different_event_new_tab",
|
||||
reschedulerWindow,
|
||||
newBooking: null,
|
||||
});
|
||||
reroutingState.setValue({
|
||||
type: "reschedule_to_different_event_new_tab",
|
||||
reschedulerWindow,
|
||||
newBooking: null,
|
||||
});
|
||||
|
||||
console.log("VALUE NOW", JSON.stringify(reroutingState));
|
||||
}
|
||||
|
||||
function reroutingPreview() {
|
||||
if (chosenRoute.action.type === RouteActionType.CustomPageMessage) {
|
||||
return <span className="text-attention">{t("reroute_preview_custom_message")}</span>;
|
||||
}
|
||||
if (chosenRoute.action.type === RouteActionType.ExternalRedirectUrl) {
|
||||
return (
|
||||
<span className="text-attention">
|
||||
{t("reroute_preview_external_redirect", { externalUrl: chosenRoute.action.value })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl) {
|
||||
// Computed value for eventTypeSlugToRedirect
|
||||
const eventTypeSlugToRedirect =
|
||||
chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl ? chosenRoute.action.value : null;
|
||||
|
||||
if (!eventTypeSlugToRedirect) {
|
||||
console.error("Event type slug to redirect must be there if action is EventTypeRedirectUrl");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span className="text-default" data-testid="reroute-preview-event-type">
|
||||
<span className="font-semibold">{t("event_type")}:</span>{" "}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
href={`${bookerUrl}/${eventTypeSlugToRedirect}`}
|
||||
rel="noreferrer"
|
||||
data-testid="test-routing-result">
|
||||
{eventTypeSlugToRedirect}
|
||||
</a>
|
||||
</span>
|
||||
<span className="text-default" data-testid="reroute-preview-hosts">
|
||||
<Tooltip content="Includes fixed hosts always and assumes that all the team members mentioned here are assigned to the event">
|
||||
<span className="font-semibold">
|
||||
{isRoundRobinScheduling ? t("reroute_preview_possible_host") : t("hosts")}:
|
||||
</span>
|
||||
</Tooltip>{" "}
|
||||
{bookingHosts()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bookingHosts() {
|
||||
if (teamMembersMatchingAttributeLogic.isPending) return t("loading");
|
||||
|
||||
const hostEmails = teamMembersMatchingAttributeLogic.data?.map((member) => member.email).join(", ");
|
||||
return hostEmails || t("no_matching_members");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function reroutingCTAs() {
|
||||
if (chosenRoute.action.type !== RouteActionType.EventTypeRedirectUrl) {
|
||||
// There are no actions to perform if the new chosen route isn't an event type redirect
|
||||
return null;
|
||||
}
|
||||
const shouldDisableCTAs = teamMembersMatchingAttributeLogic.isPending || createBookingMutation.isPending;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!isReroutingToDifferentEvent ? (
|
||||
<Button onClick={rescheduleSameEventInNewTab} disabled={shouldDisableCTAs}>
|
||||
{t("reschedule_with_different_timeslot")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={rescheduleDifferentEventInNewTab} disabled={shouldDisableCTAs}>
|
||||
{t("reschedule_to_the_new_event_with_different_timeslot")}
|
||||
</Button>
|
||||
)}
|
||||
{!isReroutingToDifferentEvent ? (
|
||||
<Button
|
||||
disabled={shouldDisableCTAs}
|
||||
loading={createBookingMutation.isPending}
|
||||
onClick={rescheduleToSameTimeslotOfSameEvent}>
|
||||
{t("reschedule_with_same_timeslot")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={shouldDisableCTAs}
|
||||
loading={createBookingMutation.isPending || isChosenEventTypeRequestInProgress}
|
||||
onClick={rescheduleToSameTimeslotOfChosenEvent}>
|
||||
{t("reschedule_with_same_timeslot_of_new_event")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function newTabReroutingStatus() {
|
||||
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_IN_PROGRESS) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-attention">
|
||||
<span>Continue with rerouting in the new</span>{" "}
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
className="text-attention underline"
|
||||
onClick={() => reroutingState.value?.reschedulerWindow?.focus()}>
|
||||
tab
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CurrentRoutingStatus = ({
|
||||
booking,
|
||||
}: {
|
||||
booking: Pick<TeamEventTypeBookingToReroute, "eventType" | "user" | "attendees">;
|
||||
}) => {
|
||||
const fullSlug = getFullSlugForEvent(booking.eventType);
|
||||
const { t } = useLocale();
|
||||
const bookerUrl = useBookerUrl();
|
||||
if (!fullSlug) return null;
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
|
||||
<h2 className="text-emphasis font-medium">{t("current_routing_status")}</h2>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span className="text-default" data-testid="current-routing-status-event-type">
|
||||
<span className="font-semibold">{t("event_type")}:</span>{" "}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
href={`${bookerUrl}/${fullSlug}`}
|
||||
rel="noreferrer"
|
||||
data-testid="test-routing-result">
|
||||
{fullSlug}
|
||||
</a>
|
||||
</span>
|
||||
<span className="text-default" data-testid="current-routing-status-organizer">
|
||||
<span className="font-semibold">{t("organizer")}:</span> <span>{booking.user.email}</span>
|
||||
</span>
|
||||
<span className="text-default" data-testid="current-routing-status-attendees">
|
||||
<span className="font-semibold">{t("attendees")}:</span>{" "}
|
||||
<span>{booking.attendees.map((attendee) => attendee.email).join(", ")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RerouteDialogContentAndFooter = ({
|
||||
booking,
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
|
||||
booking: TeamEventTypeBookingToReroute;
|
||||
}) => {
|
||||
const {
|
||||
data: responseWithForm,
|
||||
isPending: isRoutingFormLoading,
|
||||
error: formResponseFetchError,
|
||||
} = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
|
||||
formResponseId: booking.routedFromRoutingFormReponse.id,
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isRoutingFormLoading)
|
||||
return (
|
||||
<>
|
||||
<FormInputFieldsSkeleton />
|
||||
<DialogFooter>
|
||||
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button disabled data-testid="verify-new-route-button">
|
||||
{t("verify_new_route")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
if (formResponseFetchError) {
|
||||
return <div className="mb-8">{formResponseFetchError.message}</div>;
|
||||
}
|
||||
|
||||
if (!responseWithForm) return <div className="mb-8">{t("something_went_wrong")}</div>;
|
||||
|
||||
return (
|
||||
<RerouteDialogContentAndFooterWithFormResponse
|
||||
isOpenDialog={isOpenDialog}
|
||||
setIsOpenDialog={setIsOpenDialog}
|
||||
responseWithForm={responseWithForm}
|
||||
booking={booking}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RerouteDialogContentAndFooterWithFormResponse = ({
|
||||
responseWithForm,
|
||||
booking,
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
|
||||
responseWithForm: ResponseWithForm;
|
||||
booking: TeamEventTypeBookingToReroute;
|
||||
}) => {
|
||||
const form = responseWithForm.form;
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [responseFromOrganizer, setResponseFromOrganizer] = useState<FormResponse>({});
|
||||
const isResponseFromOrganizerUnpopulated = Object.keys(responseFromOrganizer).length === 0;
|
||||
const currentResponse = isResponseFromOrganizerUnpopulated
|
||||
? responseWithForm.response
|
||||
: responseFromOrganizer;
|
||||
const [chosenRoute, setChosenRoute] = useState<LocalRoute | null>(null);
|
||||
|
||||
const reroutingState = useReroutingState({
|
||||
isOpenDialog,
|
||||
});
|
||||
|
||||
const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState<
|
||||
| {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}[]
|
||||
| null
|
||||
>([]);
|
||||
|
||||
const findTeamMembersMatchingAttributeLogicMutation =
|
||||
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
|
||||
onSuccess(data) {
|
||||
setTeamMembersMatchingAttributeLogic(data.result);
|
||||
},
|
||||
});
|
||||
|
||||
function verifyRoute() {
|
||||
// Reset all states
|
||||
reroutingState.setValue(null);
|
||||
setTeamMembersMatchingAttributeLogic(null);
|
||||
|
||||
const route = findMatchingRoute({
|
||||
form,
|
||||
response: currentResponse,
|
||||
});
|
||||
|
||||
setChosenRoute(route || null);
|
||||
|
||||
if (!route) return;
|
||||
|
||||
findTeamMembersMatchingAttributeLogicMutation.mutate({
|
||||
formId: form.id,
|
||||
response: currentResponse,
|
||||
route,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Link
|
||||
className="text-emphasis text-semibold font-cal mb-4 flex underline"
|
||||
href={`/apps/routing-forms/form-edit/${form.id}`}
|
||||
target="_blank">
|
||||
{form.name}
|
||||
</Link>
|
||||
<FormInputFields
|
||||
response={currentResponse}
|
||||
disabledFields={fieldsThatCannotBeCorrected}
|
||||
form={form}
|
||||
setResponse={setResponseFromOrganizer}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CurrentRoutingStatus booking={booking} />
|
||||
{chosenRoute && (
|
||||
<NewRoutingManager
|
||||
chosenRoute={chosenRoute}
|
||||
booking={booking}
|
||||
teamMembersMatchingAttributeLogic={{
|
||||
isPending: findTeamMembersMatchingAttributeLogicMutation.isPending,
|
||||
data: teamMembersMatchingAttributeLogic,
|
||||
}}
|
||||
reroutingState={reroutingState}
|
||||
form={form}
|
||||
currentResponse={currentResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderFooter() {
|
||||
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_COMPLETE) {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={verifyRoute}
|
||||
data-testid="verify-new-route-button"
|
||||
disabled={reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS}>
|
||||
{t("verify_new_route")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
|
||||
if (reroutingState.status === ReroutingStatusEnum.REROUTING_COMPLETE) {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpenDialog(false);
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: RerouteDialogProps) => {
|
||||
const { t } = useLocale();
|
||||
const bookingEventType = booking.eventType;
|
||||
|
||||
if (!bookingEventType.team) {
|
||||
console.error("Only team event can be re-routed");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
if (!bookingEventType.team.slug) {
|
||||
console.error("Team slug must be there for rerouting");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
if (!booking.user) {
|
||||
// Not a hard requirement, but better to be strict with data.
|
||||
console.error("Booking must have a user");
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
|
||||
const teamEventTypeBooking = {
|
||||
...booking,
|
||||
user: booking.user,
|
||||
eventType: {
|
||||
...bookingEventType,
|
||||
team: {
|
||||
...bookingEventType.team,
|
||||
slug: bookingEventType.team.slug,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow preventCloseOnOutsideClick>
|
||||
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
||||
<RerouteDialogContentAndFooter
|
||||
booking={teamEventTypeBooking}
|
||||
isOpenDialog={isOpenDialog}
|
||||
setIsOpenDialog={setIsOpenDialog}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { RerouteDialog } from "../RerouteDialog";
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn((path: string) => {
|
||||
return;
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("next/navigation")>();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => mockRouter),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/processRoute", () => ({
|
||||
findMatchingRoute: vi.fn(({ form, response }) => {
|
||||
return form.routes.find((route: any) => route.__testMatching);
|
||||
}),
|
||||
}));
|
||||
|
||||
const fakeNewTabWindow = {
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
const mockOpen = vi.fn((_url: string) => {
|
||||
return fakeNewTabWindow;
|
||||
});
|
||||
|
||||
vi.stubGlobal("open", mockOpen);
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||
return (
|
||||
<div data-testid="mock-form-input-fields">
|
||||
{form.fields?.map((field: any) => (
|
||||
<div key={field.id}>
|
||||
<label data-testid={`mock-form-field-${field.id}-label`}>{field.label}</label>
|
||||
<div data-testid={`mock-form-field-${field.id}-value`}>{response[field.id]?.value}</div>
|
||||
</div>
|
||||
))}
|
||||
<div data-testid="mock-form-field-disabled-fields-identifiers">{disabledFields.join(", ")}</div>
|
||||
<button
|
||||
data-testid="mock-form-update-response-button"
|
||||
onClick={() =>
|
||||
setResponse({
|
||||
"company-size": {
|
||||
label: "Company Size",
|
||||
value: "small",
|
||||
},
|
||||
country: {
|
||||
label: "Country",
|
||||
value: "usa",
|
||||
},
|
||||
})
|
||||
}>
|
||||
Test Update Response
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
FormInputFieldsSkeleton: vi.fn(() => <div data-testid="mock-form-input-fields-skeleton" />),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: vi.fn(() => ({ t: (key: string) => key })),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({
|
||||
default: vi.fn(() => {
|
||||
return {
|
||||
setQuery: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const actual = await importOriginal<any>("@calcom/ui");
|
||||
return {
|
||||
...actual,
|
||||
Tooltip: vi.fn(({ children }) => <div data-testid="mock-tooltip">{children}</div>),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
appRoutingForms: {
|
||||
getResponseWithFormFields: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: {
|
||||
form: {
|
||||
id: "form-id",
|
||||
name: "Test Form",
|
||||
fields: [
|
||||
{
|
||||
id: "company-size",
|
||||
label: "Company Size",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Small", value: "small" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "Large", value: "large" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
label: "Country",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "USA", value: "usa" },
|
||||
{ label: "Canada", value: "canada" },
|
||||
{ label: "UK", value: "uk" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
id: "mock-route-id",
|
||||
action: {
|
||||
eventTypeId: 123,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "team/test-team/new-test-event",
|
||||
},
|
||||
__testMatching: true,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: RouteActionType.CustomPageMessage,
|
||||
value: "456",
|
||||
},
|
||||
__testMatching: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
"company-size": {
|
||||
label: "Company Size",
|
||||
value: "small",
|
||||
},
|
||||
country: {
|
||||
label: "Country",
|
||||
value: "usa",
|
||||
},
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
})),
|
||||
},
|
||||
findTeamMembersMatchingAttributeLogic: {
|
||||
useMutation: vi.fn(({ onSuccess }) => {
|
||||
return {
|
||||
mutate: vi.fn(() => {
|
||||
onSuccess({
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Matching User 1",
|
||||
email: "matching-user-1@example.com",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Matching User 2",
|
||||
email: "matching-user-2@example.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
get: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: {
|
||||
eventType: {
|
||||
id: "123",
|
||||
title: "Mocked Event Type",
|
||||
slug: "mocked-event-type",
|
||||
length: 30,
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: { slug: "mocked-team" },
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
|
||||
__testOnSuccess({
|
||||
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: vi.fn(({ onSuccess }) => {
|
||||
return {
|
||||
mutate: vi.fn((payload) => {
|
||||
mockMutateFn({
|
||||
...payload,
|
||||
__testOnSuccess: onSuccess,
|
||||
});
|
||||
}),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
onSuccess: onSuccess,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
async function mockMessageFromOpenedTab({ type, data }: { type: string; data: any }) {
|
||||
const messageReceivedPromise = new Promise<boolean>((resolve) => {
|
||||
window.addEventListener("message", () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
window.postMessage(
|
||||
{
|
||||
type,
|
||||
data,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
return messageReceivedPromise;
|
||||
}
|
||||
|
||||
async function expectEventTypeInfoInCurrentRouting({
|
||||
eventTypeText,
|
||||
eventTypeHref,
|
||||
}: {
|
||||
eventTypeText: string;
|
||||
eventTypeHref: string;
|
||||
}) {
|
||||
const eventTypeEl = screen.getByTestId("current-routing-status-event-type");
|
||||
expect(eventTypeEl).toHaveTextContent(eventTypeText);
|
||||
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
|
||||
}
|
||||
|
||||
async function expectEventTypeInfoInReroutePreview({
|
||||
eventTypeText,
|
||||
eventTypeHref,
|
||||
}: {
|
||||
eventTypeText: string;
|
||||
eventTypeHref: string;
|
||||
}) {
|
||||
const eventTypeEl = screen.getByTestId("reroute-preview-event-type");
|
||||
expect(eventTypeEl).toHaveTextContent(eventTypeText);
|
||||
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
|
||||
}
|
||||
|
||||
function expectOrganizerInfoInCurrentRouting({ organizerText }: { organizerText: string }) {
|
||||
const organizerEl = screen.getByTestId("current-routing-status-organizer");
|
||||
expect(organizerEl).toHaveTextContent(organizerText);
|
||||
}
|
||||
|
||||
function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText: string }) {
|
||||
const attendeesEl = screen.getByTestId("current-routing-status-attendees");
|
||||
expect(attendeesEl).toHaveTextContent(attendeesText);
|
||||
}
|
||||
const userWhoBooked = { id: 1, name: "Test User", email: "user@example.com" };
|
||||
const mockBooking = {
|
||||
id: 1,
|
||||
uid: "original-booking-uid",
|
||||
title: "Test Booking",
|
||||
startTime: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString(),
|
||||
metadata: {},
|
||||
responses: {},
|
||||
attendees: [{ email: "attendee@example.com", name: "Attendee", timeZone: "UTC", locale: "en" }],
|
||||
eventType: {
|
||||
id: 1,
|
||||
slug: "test-event",
|
||||
team: { slug: "test-team" },
|
||||
length: 60,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
title: "Test Event",
|
||||
},
|
||||
user: userWhoBooked,
|
||||
routedFromRoutingFormReponse: { id: 1 },
|
||||
status: BookingStatus.ACCEPTED, // Add this line
|
||||
};
|
||||
|
||||
const buildBooking = () => {
|
||||
return {
|
||||
...mockBooking,
|
||||
};
|
||||
};
|
||||
|
||||
describe("RerouteDialog", () => {
|
||||
const mockSetIsOpenDialog = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the dialog when open", () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
|
||||
expect(screen.getByText("reroute_booking")).toBeInTheDocument();
|
||||
expect(screen.getByText("reroute_booking_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't render the dialog when closed", () => {
|
||||
render(
|
||||
<RerouteDialog isOpenDialog={false} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
);
|
||||
|
||||
expect(screen.queryByText("reroute_booking")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays current routing status", async () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
expect(screen.getByText("current_routing_status")).toBeInTheDocument();
|
||||
|
||||
expectEventTypeInfoInCurrentRouting({
|
||||
eventTypeText: "team/test-team/test-event",
|
||||
eventTypeHref: "https://cal.com/team/test-team/test-event",
|
||||
});
|
||||
expectOrganizerInfoInCurrentRouting({
|
||||
organizerText: "user@example.com",
|
||||
});
|
||||
expectAttendeesInfoInCurrentRouting({
|
||||
attendeesText: "attendee@example.com",
|
||||
});
|
||||
// screen.logTestingPlaygroundURL()
|
||||
});
|
||||
|
||||
test("verify_new_route button is enabled even when form fields are not filled", async () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
await expect(screen.getByText("verify_new_route")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("disabledFields are passed to FormInputFields with value ['email'] - email field is disabled", async () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
expect(screen.getByTestId("mock-form-field-disabled-fields-identifiers")).toHaveTextContent(/^email$/);
|
||||
});
|
||||
|
||||
test("Expect form fields and name to be rendered", async () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
expect(screen.getByText("Test Form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-form-field-company-size-label")).toHaveTextContent("Company Size");
|
||||
expect(screen.getByTestId("mock-form-field-company-size-value")).toHaveTextContent("small");
|
||||
expect(screen.getByTestId("mock-form-field-country-label")).toHaveTextContent("Country");
|
||||
expect(screen.getByTestId("mock-form-field-country-value")).toHaveTextContent("usa");
|
||||
});
|
||||
|
||||
test("cancel button closes the dialog", async () => {
|
||||
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
|
||||
|
||||
expect(screen.getByText("cancel")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("cancel"));
|
||||
expect(mockSetIsOpenDialog).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
describe("New Routing tests", () => {
|
||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||
render(
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
|
||||
expectEventTypeInfoInReroutePreview({
|
||||
eventTypeText: "team/test-team/new-test-event",
|
||||
eventTypeHref: "https://cal.com/team/test-team/new-test-event",
|
||||
});
|
||||
await expect(screen.getByText("verify_new_route")).toBeEnabled();
|
||||
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("reroute_preview_possible_host");
|
||||
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("matching-user-1@example.com");
|
||||
|
||||
expect(screen.getByText("reschedule_to_the_new_event_with_different_timeslot")).toBeInTheDocument();
|
||||
expect(screen.getByText("reschedule_with_same_timeslot_of_new_event")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("New tab rescheduling", () => {
|
||||
test("new tab is closed when new booking is rerouted", async () => {
|
||||
render(
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||
await mockMessageFromOpenedTab({
|
||||
type: "CAL:rescheduleBookingSuccessfulV2",
|
||||
data: {
|
||||
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleTabUrl = mockOpen.mock.calls[0][0] as unknown as string;
|
||||
const rescheduleTabUrlObject = new URL(rescheduleTabUrl, "http://mockcal.com");
|
||||
expect(Object.fromEntries(rescheduleTabUrlObject.searchParams.entries())).toEqual(
|
||||
expect.objectContaining({
|
||||
rescheduleUid: mockBooking.uid,
|
||||
"cal.rerouting": "true",
|
||||
// Shouldn't include the user who booked the booking
|
||||
"cal.routedTeamMemberIds": "2",
|
||||
"cal.reroutingFormResponses": JSON.stringify({
|
||||
"company-size": {
|
||||
value: "small",
|
||||
},
|
||||
country: {
|
||||
value: "usa",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(fakeNewTabWindow.close).toHaveBeenCalled();
|
||||
expectToBeNavigatedToBookingPage({
|
||||
booking: {
|
||||
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Rescheduling with same timeslot works", async () => {
|
||||
render(
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||
expect(mockMutateFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rescheduleUid: mockBooking.uid,
|
||||
// Shouldn't include the user who booked the booking
|
||||
routedTeamMemberIds: [2],
|
||||
reroutingFormResponses: {
|
||||
"company-size": {
|
||||
value: "small",
|
||||
},
|
||||
country: {
|
||||
value: "usa",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expectToBeNavigatedToBookingPage({
|
||||
booking: {
|
||||
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clickVerifyNewRouteButton() {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
});
|
||||
}
|
||||
|
||||
function clickRescheduleToTheNewEventWithDifferentTimeslotButton() {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("reschedule_to_the_new_event_with_different_timeslot"));
|
||||
});
|
||||
}
|
||||
|
||||
function clickRescheduleWithSameTimeslotOfChosenEventButton() {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("reschedule_with_same_timeslot_of_new_event"));
|
||||
});
|
||||
}
|
||||
|
||||
function expectToBeNavigatedToBookingPage({
|
||||
booking,
|
||||
}: {
|
||||
booking: {
|
||||
uid: string;
|
||||
};
|
||||
}) {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith(`/booking/${booking.uid}?cal.rerouting=true`);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const CustomEmailTextField = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border-default mt-2 flex items-center rounded-md border ${
|
||||
className={`border-default mt-2 flex w-full items-center rounded-md border ${
|
||||
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
|
||||
}`}>
|
||||
<TextField
|
||||
|
||||
@@ -57,6 +57,7 @@ const SecondaryEmailModal = ({
|
||||
type="creation"
|
||||
data-testid="secondary-email-add-dialog">
|
||||
<Form form={formMethods} handleSubmit={handleAddEmail}>
|
||||
<div className="text-subtle mb-4 text-sm">{t("change_email_hint")}</div>
|
||||
<TextField
|
||||
label={t("email_address")}
|
||||
data-testid="secondary-email-input"
|
||||
|
||||
@@ -159,14 +159,14 @@ export const OAuthClientCard = ({
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit/webhooks`)}>
|
||||
Webhooks
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
color="secondary"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
|
||||
@@ -212,7 +212,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
autoCorrect="none"
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
"border-l-1 mb-0 mt-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
|
||||
"border-l-1 my-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
|
||||
isInputUsernamePremium
|
||||
? "border border-orange-400 focus:border focus:border-orange-400"
|
||||
: "border focus:border",
|
||||
@@ -262,7 +262,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
)}
|
||||
</div>
|
||||
{paymentMsg}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
|
||||
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent
|
||||
|
||||
@@ -168,7 +168,8 @@ export const handleSeatsEventTypeOnBooking = async (
|
||||
if (seatAttendee) {
|
||||
const attendee = bookingInfo?.attendees?.find((a) => {
|
||||
return (
|
||||
a.email === seatAttendee?.attendee?.email || a.phoneNumber === seatAttendee?.attendee?.phoneNumber
|
||||
a.email === seatAttendee?.attendee?.email ||
|
||||
(a.phoneNumber && a.phoneNumber === seatAttendee?.attendee?.phoneNumber)
|
||||
);
|
||||
});
|
||||
bookingInfo["attendees"] = attendee ? [attendee] : [];
|
||||
|
||||
@@ -56,6 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
eventTypeId: eventData.id,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -10,7 +11,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
|
||||
@@ -29,7 +30,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
@@ -54,43 +54,54 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
parent: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventData = team.eventTypes[0];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
const ssr = await ssrInit(context);
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
eventTypeId: eventData.id,
|
||||
entity: {
|
||||
fromRedirectOfNonOrgLink,
|
||||
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
|
||||
teamSlug: team.slug ?? null,
|
||||
name: team.parent?.name ?? team.name ?? null,
|
||||
},
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
|
||||
},
|
||||
booking,
|
||||
user: teamSlug,
|
||||
@@ -98,20 +109,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
|
||||
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
orgBannerUrl: eventData?.team?.parent?.bannerUrl ?? "",
|
||||
orgBannerUrl: team.parent?.bannerUrl ?? "",
|
||||
teamMemberEmail: await getTeamMemberEmail(eventData, email as string),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type EventData = RouterOutputs["viewer"]["public"]["event"];
|
||||
|
||||
async function getTeamMemberEmail(eventData: EventData, email?: string): Promise<string | null> {
|
||||
async function getTeamMemberEmail(
|
||||
eventData: {
|
||||
id: number;
|
||||
isInstantEvent: boolean;
|
||||
schedulingType: SchedulingType | null;
|
||||
metadata: Prisma.JsonValue | null;
|
||||
length: number;
|
||||
},
|
||||
email?: string
|
||||
): Promise<string | null> {
|
||||
// Pre-requisites
|
||||
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
|
||||
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.id);
|
||||
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
|
||||
if (!crmContactOwnerEmail) return null;
|
||||
// Determine if the contactOwner is a part of the event type
|
||||
const contactOwnerQuery = await prisma.user.findFirst({
|
||||
|
||||
@@ -167,7 +167,6 @@ export const config = {
|
||||
"/event-types",
|
||||
"/future/event-types/",
|
||||
"/settings/admin/:path*",
|
||||
"/future/settings/admin/:path*",
|
||||
"/apps/installed/:category/",
|
||||
"/future/apps/installed/:category/",
|
||||
"/apps/:slug/",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -96,17 +95,3 @@ export default function Apps({ categories, appStore, userAdminTeams }: PageProps
|
||||
</AppsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const LayoutWrapper = (page: React.ReactElement) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title={t("app_store")}
|
||||
description={t("app_store_description")}
|
||||
withoutMain={true}
|
||||
hideHeadingOnMobile>
|
||||
{page}
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
|
||||
(data && (data.user.role === MembershipRole.OWNER || data.user.role === MembershipRole.ADMIN)) ?? false;
|
||||
const isOrgAndPrivate = data?.isOrganization && data.isPrivate;
|
||||
|
||||
const canViewTeamAvailability = isOrg && (isOrgAdminOrOwner || !isOrgAndPrivate);
|
||||
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgAndPrivate;
|
||||
|
||||
const toggleGroupOptions = [{ value: "mine", label: t("my_availability") }];
|
||||
|
||||
@@ -233,7 +233,7 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
|
||||
</div>
|
||||
}>
|
||||
{searchParams?.get("type") === "team" && canViewTeamAvailability ? (
|
||||
<AvailabilitySliderTable userTimeFormat={me?.data?.timeFormat ?? null} />
|
||||
<AvailabilitySliderTable userTimeFormat={me?.data?.timeFormat ?? null} isOrg={isOrg} />
|
||||
) : (
|
||||
<AvailabilityListWithQuery />
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FiltersContainer } from "@calcom/features/bookings/components/FiltersCo
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -18,7 +19,6 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
|
||||
import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui";
|
||||
|
||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
@@ -78,7 +78,6 @@ export default function Bookings() {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
@@ -88,7 +87,6 @@ export default function Bookings() {
|
||||
},
|
||||
},
|
||||
{
|
||||
// first render has status `undefined`
|
||||
enabled: true,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user