Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9f3a8060 | ||
|
|
bc1d73e9fe | ||
|
|
eca01be58c | ||
|
|
4ad1127184 | ||
|
|
76b9b9b0fe | ||
|
|
ff5c6de5da | ||
|
|
eafb61f515 | ||
|
|
d0ca0dab53 | ||
|
|
e6f561c3d7 | ||
|
|
b7c5cfcf02 | ||
|
|
b1bcbe9dfb | ||
|
|
19e1714794 | ||
|
|
e3e624464d | ||
|
|
dc3c7b43bf | ||
|
|
83736785b7 | ||
|
|
83b6b7caf9 |
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
@@ -0,0 +1,9 @@
|
||||
- [x] Refactor individual calendar links functions to create the variables and just use the variables in the string literal instead of functions being called in the string literal
|
||||
- [x] Must ensure during review that logically there is no change in the functionality of booking-single-view.tsx
|
||||
|
||||
|
||||
- Testing
|
||||
- [x] Verify Google Calendar, Ics, Office 365 - Compare with Production
|
||||
- [x] Event Description, Booking title, Booking location, Start time, End time - All correct
|
||||
- [x] Recurring event?
|
||||
- [x] Dynamic Group booking?
|
||||
+30
-1
@@ -54,9 +54,29 @@ CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
|
||||
|
||||
## Running the app
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ yarn run start
|
||||
```
|
||||
|
||||
OR if you don't want to use docker, you can run following command.
|
||||
|
||||
```bash
|
||||
$ yarn dev:no-docker
|
||||
```
|
||||
|
||||
Additionally you can run following command(in different terminal) to ensure that any change in any of the dependencies is rebuilt and detected. It watches platform-libraries, platform-constants, platform-enums, platform-utils, platform-types.
|
||||
|
||||
```bash
|
||||
$ yarn run dev:build:watch
|
||||
```
|
||||
|
||||
If you are making changes in packages/platform/libraries, you should run the following command too that would connect your local packages/platform/libraries to the api/v2
|
||||
|
||||
```bash
|
||||
$ yarn local
|
||||
```
|
||||
|
||||
# watch mode
|
||||
$ yarn run start:dev
|
||||
@@ -65,6 +85,9 @@ $ yarn run start:dev
|
||||
$ yarn run start:prod
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
@@ -74,6 +97,12 @@ $ yarn run test
|
||||
# e2e tests
|
||||
$ yarn run test:e2e
|
||||
|
||||
# e2e tests in watch mode
|
||||
$ yarn test:e2e:watch
|
||||
|
||||
# run specific e2e test file in watch mode
|
||||
$ yarn test:e2e:watch --testPathPattern=filePath
|
||||
|
||||
# test coverage
|
||||
$ yarn run test:cov
|
||||
```
|
||||
|
||||
@@ -9,8 +9,14 @@
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
|
||||
"dev:build:watch": "concurrently --names \"libraries,lru-fix,constants,enums,utils,types\" \"yarn _dev:build:watch:libraries\" \"yarn _dev:build:watch:libraries:lru-fix\" \"yarn _dev:build:watch:constants\" \"yarn _dev:build:watch:enums\" \"yarn _dev:build:watch:utils\" \"yarn _dev:build:watch:types\"",
|
||||
"_dev:build:watch:libraries": "yarn workspace @calcom/platform-libraries build:watch",
|
||||
"_dev:build:watch:libraries:lru-fix": "yarn workspace @calcom/platform-libraries watch-lru-fix",
|
||||
"_dev:build:watch:constants": "yarn workspace @calcom/platform-constants build:watch",
|
||||
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
|
||||
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
|
||||
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@@ -32,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.144",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.147",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -57,6 +63,7 @@
|
||||
"bull": "^4.12.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -14,8 +14,10 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
@@ -101,7 +103,8 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -343,7 +346,8 @@ export class BookingsController_2024_04_15 {
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
// Access Token
|
||||
return this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
return ownerId;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -351,6 +355,30 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnerIdRescheduledBooking(
|
||||
request: Request,
|
||||
platformClientId?: string
|
||||
): Promise<number | undefined> {
|
||||
if (
|
||||
platformClientId &&
|
||||
request.body.rescheduledBy &&
|
||||
!request.body.rescheduledBy.includes(platformClientId)
|
||||
) {
|
||||
request.body.rescheduledBy = OAuthClientUsersService.getOAuthUserEmail(
|
||||
platformClientId,
|
||||
request.body.rescheduledBy
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.rescheduledBy) {
|
||||
if (request.body.rescheduledBy !== request.body.responses.email) {
|
||||
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
|
||||
if (!eventTypeId) {
|
||||
return undefined;
|
||||
@@ -395,7 +423,9 @@ export class BookingsController_2024_04_15 {
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const requestId = req.get("X-Request-Id");
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const userId = clone.body.rescheduleUid
|
||||
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwnerId(req);
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
+10
-8
@@ -12,6 +12,7 @@ import {
|
||||
CreateManagedUserData,
|
||||
CreateManagedUserOutput,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -140,7 +141,9 @@ describe("Managed user bookings 2024-04-15", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -193,7 +196,9 @@ describe("Managed user bookings 2024-04-15", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -220,7 +225,9 @@ describe("Managed user bookings 2024-04-15", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -444,11 +451,6 @@ describe("Managed user bookings 2024-04-15", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Validate,
|
||||
} from "class-validator";
|
||||
|
||||
import { RESCHEDULED_BY_DOCS } from "@calcom/platform-types";
|
||||
|
||||
class Location {
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
@@ -76,6 +78,13 @@ export class CreateBookingInput_2024_04_15 {
|
||||
@ApiPropertyOptional()
|
||||
rescheduleUid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS })
|
||||
@Validate((value: string) => !value || isEmail(value), {
|
||||
message: "Invalid rescheduledBy email format",
|
||||
})
|
||||
rescheduledBy?: string;
|
||||
|
||||
@IsTimeZone()
|
||||
@ApiProperty()
|
||||
timeZone!: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -98,7 +98,7 @@ export class BookingsRepository_2024_08_13 {
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
const booking = await this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -108,6 +108,15 @@ export class BookingsRepository_2024_08_13 {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...booking,
|
||||
responses: booking.responses as Prisma.JsonObject,
|
||||
metadata: booking.metadata as Prisma.JsonObject | null,
|
||||
};
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
@@ -335,4 +336,22 @@ export class BookingsController_2024_08_13 {
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/calendar-links")
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiOperation({
|
||||
summary: "Get 'Add to Calendar' links for a booking",
|
||||
description:
|
||||
"Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
|
||||
const calendarLinks = await this.bookingsService.getCalendarLinks(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: calendarLinks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Calendar Links", () => {
|
||||
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 = `calendar-links-2024-08-13-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let booking: Booking;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `confirm-bookings-2024-08-13-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `calendar-links-2024-08-13-${randomString()}-schedule`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `calendar-links-2024-08-13-${randomString()}-event-type`,
|
||||
slug: `calendar-links-2024-08-13-${randomString()}-event-type-slug`,
|
||||
length: 60,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
booking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2020, 0, 8, 12, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)),
|
||||
uid: `calendar-links-2024-08-13-booking-${randomString()}`,
|
||||
title: "Test Booking",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: event.id,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "johndoe@example.com",
|
||||
name: "John Doe",
|
||||
locale: "en",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
create: {
|
||||
name: "John Doe",
|
||||
email: "johndoe@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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 get calendar links for a booking", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${booking.uid}/calendar-links`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
|
||||
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
|
||||
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
|
||||
|
||||
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
|
||||
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
|
||||
expect(microsoftOutlookLink).toMatch(/^https:\/\/outlook\.live\.com\//);
|
||||
expect(icsLink).toMatch(/^data:text\/calendar,/);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent booking", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/bookings/non-existent-uid/calendar-links`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+172
-114
@@ -94,6 +94,8 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
let emailsDisabledSetup: EmailSetup;
|
||||
|
||||
const authEmail = `confirm-emails-2024-08-13-admin-${randomString()}@api.com`;
|
||||
let userEmailsEnabled = "";
|
||||
const userEmailsDisabled = `confirm-emails-2024-08-13-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -141,8 +143,12 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
async function setupEnabledEmails() {
|
||||
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
|
||||
|
||||
userEmailsEnabled = `confirm-emails-2024-08-13-user-${randomString()}+${
|
||||
oAuthClientEmailsEnabled.id
|
||||
}@api.com`;
|
||||
|
||||
const user = await userRepositoryFixture.create({
|
||||
email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`,
|
||||
email: userEmailsEnabled,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClientEmailsEnabled.id,
|
||||
@@ -179,7 +185,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
|
||||
|
||||
const user = await userRepositoryFixture.create({
|
||||
email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`,
|
||||
email: userEmailsDisabled,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClientEmailsDisabled.id,
|
||||
@@ -282,9 +288,6 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,10 +341,6 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeDeclinedEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -351,117 +350,176 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should send an email when creating a booking that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: emailsEnabledSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
describe("confirming booking that requires confirmation and rescheduling it", () => {
|
||||
it("should send an email when creating a booking that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: emailsEnabledSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("pending");
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("pending");
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an email when confirming a booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescheduling emails", () => {
|
||||
it("should send confirmation emails when organizer reschedules a booking that requires confirmation", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 30, 0)).toISOString(),
|
||||
rescheduledBy: userEmailsEnabled,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("accepted");
|
||||
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should send requested rescheduling emails when attendee rescheduling a booking that requires confirmation", async () => {
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.rescheduledBookingUid}/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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("pending");
|
||||
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an email when confirming a booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
});
|
||||
});
|
||||
describe("declining booking that requires confirmation", () => {
|
||||
it("should send an email when creating a booking that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: emailsEnabledSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
it("should send an email when creating a booking that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: emailsEnabledSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("pending");
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
expect(responseBody.data.status).toEqual("pending");
|
||||
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an email when declining a booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
});
|
||||
it("should send an email when declining a booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-8
@@ -7,6 +7,7 @@ import {
|
||||
CreateManagedUserData,
|
||||
CreateManagedUserOutput,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -149,7 +150,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -202,7 +205,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -229,7 +234,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -470,11 +477,6 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CalendarLink {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The label of the calendar link",
|
||||
})
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The link to the calendar",
|
||||
})
|
||||
link!: string;
|
||||
}
|
||||
|
||||
export class CalendarLinksOutput_2024_08_13 {
|
||||
@ApiProperty({
|
||||
description: "The status of the request, always 'success' for successful responses",
|
||||
example: SUCCESS_STATUS,
|
||||
})
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Calendar links for the booking",
|
||||
type: [CalendarLink],
|
||||
})
|
||||
data!: CalendarLink[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
@@ -15,6 +16,7 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
handleNewRecurringBooking,
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
handleInstantMeeting,
|
||||
handleCancelBooking,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
roundRobinManualReassignment,
|
||||
handleMarkNoShow,
|
||||
confirmBookingHandler,
|
||||
getCalendarLinks,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import {
|
||||
@@ -549,4 +552,32 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
}
|
||||
|
||||
async getCalendarLinks(bookingUid: string): Promise<CalendarLink[]> {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (!booking.eventTypeId) {
|
||||
throw new BadRequestException(`Booking with uid ${bookingUid} has no event type`);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdIncludeUsersAndTeam(booking.eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new BadRequestException(`Booking with uid ${bookingUid} has no event type`);
|
||||
}
|
||||
// TODO: Maybe we should get locale from query params?
|
||||
return getCalendarLinks({
|
||||
booking,
|
||||
eventType: {
|
||||
...eventType,
|
||||
// TODO: Support dynamic event bookings later. It would require a slug input it seems
|
||||
isDynamic: false,
|
||||
},
|
||||
// It can be made customizable through the API endpoint later.
|
||||
t: await getTranslation("en", "common"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -75,7 +77,8 @@ export class InputBookingsService_2024_08_13 {
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async createBookingRequest(
|
||||
@@ -314,7 +317,24 @@ export class InputBookingsService_2024_08_13 {
|
||||
);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
let userId: number | undefined = undefined;
|
||||
|
||||
if (
|
||||
oAuthClientParams &&
|
||||
request.body.rescheduledBy &&
|
||||
!request.body.rescheduledBy.includes(oAuthClientParams.platformClientId)
|
||||
) {
|
||||
request.body.rescheduledBy = OAuthClientUsersService.getOAuthUserEmail(
|
||||
oAuthClientParams.platformClientId,
|
||||
request.body.rescheduledBy
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.rescheduledBy) {
|
||||
if (request.body.rescheduledBy !== bodyTransformed.responses.email) {
|
||||
userId = (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const location = await this.getRescheduleBookingLocation(bookingUid);
|
||||
if (oAuthClientParams) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
@Injectable()
|
||||
export class PlatformBookingsService {
|
||||
constructor(
|
||||
private readonly oAuthClientsUsersService: OAuthClientUsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
@@ -25,7 +24,7 @@ export class PlatformBookingsService {
|
||||
if (!attendeeEmail.includes(platformClientId)) {
|
||||
// note(Lauris): we need to do this when managed user is booking another managed user and the managed user doing the booking entered their email without oAuth client id
|
||||
// or if one of the guests added are managed users without oAuth client id in their email.
|
||||
const oAuthUserEmail = this.oAuthClientsUsersService.getOAuthUserEmail(platformClientId, attendeeEmail);
|
||||
const oAuthUserEmail = OAuthClientUsersService.getOAuthUserEmail(platformClientId, attendeeEmail);
|
||||
const oAuthUser = await this.usersRepository.findByEmail(oAuthUserEmail);
|
||||
if (oAuthUser) {
|
||||
return oAuthUserEmail;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -76,6 +75,22 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeByIdIncludeUsersAndTeam(eventTypeId: number) {
|
||||
const eventType = await this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { users: true, team: true },
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...eventType,
|
||||
recurringEvent: eventType.recurringEvent as Prisma.JsonObject | null | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getEventTypeByIdWithOwnerAndTeam(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { transformApiScheduleOverrides, transformApiScheduleAvailability } from "@calcom/platform-libraries";
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries/schedules";
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayNotEmpty, isEmail, IsOptional } from "class-validator";
|
||||
|
||||
import { Pagination } from "@calcom/platform-types";
|
||||
|
||||
export class GetManagedUsersInput extends Pagination {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((email: string) => {
|
||||
const trimmed = email.trim();
|
||||
if (!isEmail(trimmed)) {
|
||||
throw new BadRequestException(`Invalid email ${trimmed}`);
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ArrayNotEmpty({ message: "emails cannot be empty." })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.",
|
||||
example: "?emails=email1@example.com,email2@example.com",
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
+165
-17
@@ -7,6 +7,7 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -90,8 +91,11 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const platformAdminEmail = `oauth-client-users-admin-${randomString()}@api.com`;
|
||||
let platformAdmin: User;
|
||||
|
||||
const userEmail = `oauth-client-users-user-${randomString()}@api.com`;
|
||||
const userEmail = `oauth-client-users-user-${randomString(5)}@api.com`;
|
||||
const userTimeZone = "Europe/Rome";
|
||||
const userEmailTwo = `oauth-client-users-user-2-${randomString(5)}@api.com`;
|
||||
const userTimeZoneTwo = "Europe/Rome";
|
||||
let postResponseDataTwo: CreateManagedUserOutput["data"];
|
||||
|
||||
const userEmail2 = `oauth-client-users-user2-${randomString()}@api.com`;
|
||||
const userTimeZone2 = "America/New_York";
|
||||
@@ -215,7 +219,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
|
||||
@@ -225,7 +231,48 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email);
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
|
||||
await userHasOnlyOneSchedule(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it(`/POST with default event types`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmailTwo,
|
||||
timeZone: userTimeZoneTwo,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Bob Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
|
||||
postResponseDataTwo = responseBody.data;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
|
||||
await userHasOnlyOneSchedule(responseBody.data.user.id);
|
||||
@@ -255,7 +302,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
getOAuthUserEmail(oAuthClientEventTypesDisabled.id, requestBody.email)
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClientEventTypesDisabled.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
@@ -266,17 +313,17 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email);
|
||||
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1);
|
||||
await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id);
|
||||
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
|
||||
await userHasOnlyOneSchedule(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string) {
|
||||
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string, usersCount: number) {
|
||||
const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClientId);
|
||||
const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail);
|
||||
|
||||
expect(oAuthUsers?.length).toEqual(1);
|
||||
expect(oAuthUsers?.length).toEqual(usersCount);
|
||||
expect(newOAuthUser?.email).toEqual(userEmail);
|
||||
}
|
||||
|
||||
@@ -359,6 +406,110 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data?.length).toBeGreaterThan(0);
|
||||
expect(responseBody.data[0].email).toEqual(postResponseData.user.email);
|
||||
expect(responseBody.data[0].name).toEqual(postResponseData.user.name);
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by original email`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${userEmail}`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetManagedUsersOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
});
|
||||
|
||||
it(`/GET: managed users by original emails`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${userEmail},${userEmailTwo}`
|
||||
)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetManagedUsersOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by oAuth email`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
// note(Lauris): we use encodeURIComponent because email stored on our side includes "+" which without encoding becomes an empty space.
|
||||
.get(
|
||||
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${encodeURIComponent(
|
||||
postResponseData.user.email
|
||||
)}`
|
||||
)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetManagedUsersOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
});
|
||||
|
||||
it(`should error /GET if managed user email is invalid`, async () => {
|
||||
const invalidEmail = "invalid-email";
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${invalidEmail}`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body?.error?.message).toEqual(`Invalid email ${invalidEmail}`);
|
||||
});
|
||||
|
||||
it(`/GET: managed users by oAuth emails`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
// note(Lauris): we use encodeURIComponent because email stored on our side includes "+" which without encoding becomes an empty space.
|
||||
.get(
|
||||
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${encodeURIComponent(
|
||||
postResponseData.user.email
|
||||
)},${encodeURIComponent(postResponseDataTwo.user.email)}`
|
||||
)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.set("Origin", `${CLIENT_REDIRECT_URI}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetManagedUsersOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
@@ -372,7 +523,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userEmail));
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail)
|
||||
);
|
||||
});
|
||||
|
||||
it(`/PUT/:id`, async () => {
|
||||
@@ -390,7 +543,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail));
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)
|
||||
);
|
||||
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
|
||||
});
|
||||
|
||||
@@ -402,13 +557,6 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
function getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
const email = `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id);
|
||||
@@ -452,7 +600,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let postResponseData: CreateManagedUserOutput["data"];
|
||||
|
||||
const userEmail = `oauth-client-users-user-${randomString()}@api.com`;
|
||||
const userEmail = `oauth-client-users-user-${randomString(5)}@api.com`;
|
||||
const userTimeZone = "Europe/Rome";
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
+4
-9
@@ -3,6 +3,7 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
@@ -57,20 +58,14 @@ export class OAuthClientUsersController {
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async getManagedUsers(
|
||||
@Param("clientId") oAuthClientId: string,
|
||||
@Query() queryParams: Pagination
|
||||
@Query() queryParams: GetManagedUsersInput
|
||||
): Promise<GetManagedUsersOutput> {
|
||||
this.logger.log(`getting managed users with data for OAuth Client with ID ${oAuthClientId}`);
|
||||
const { offset, limit } = queryParams;
|
||||
|
||||
const existingUsers = await this.userRepository.findManagedUsersByOAuthClientId(
|
||||
oAuthClientId,
|
||||
offset ?? 0,
|
||||
limit ?? 50
|
||||
);
|
||||
const managedUsers = await this.oAuthClientUsersService.getManagedUsers(oAuthClientId, queryParams);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: existingUsers.map((user) => this.getResponseUser(user)),
|
||||
data: managedUsers.map((user) => this.getResponseUser(user)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
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";
|
||||
@@ -35,7 +36,7 @@ export class OAuthClientUsersService {
|
||||
"You cannot create a managed user outside of an organization - the OAuth client does not belong to any organization."
|
||||
);
|
||||
} else {
|
||||
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
user = (
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
invitations: [
|
||||
@@ -97,13 +98,30 @@ export class OAuthClientUsersService {
|
||||
}
|
||||
|
||||
async getExistingUserByEmail(oAuthClientId: string, email: string) {
|
||||
const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email);
|
||||
const oAuthEmail = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, email);
|
||||
return await this.userRepository.findByEmail(oAuthEmail);
|
||||
}
|
||||
|
||||
async getManagedUsers(oAuthClientId: string, queryParams: GetManagedUsersInput) {
|
||||
const { offset, limit, emails } = queryParams;
|
||||
|
||||
const oAuthEmails = emails?.map((email) =>
|
||||
email.includes(oAuthClientId) ? email : OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, email)
|
||||
);
|
||||
|
||||
const managedUsers = await this.userRepository.findManagedUsersByOAuthClientIdAndEmails(
|
||||
oAuthClientId,
|
||||
offset ?? 0,
|
||||
limit ?? 50,
|
||||
oAuthEmails
|
||||
);
|
||||
|
||||
return managedUsers;
|
||||
}
|
||||
|
||||
async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) {
|
||||
if (body.email) {
|
||||
const emailWithOAuthId = this.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
const emailWithOAuthId = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
body.email = emailWithOAuthId;
|
||||
const newUsername = slugify(emailWithOAuthId);
|
||||
await this.userRepository.updateUsername(userId, newUsername);
|
||||
@@ -112,7 +130,7 @@ export class OAuthClientUsersService {
|
||||
return this.userRepository.update(userId, body);
|
||||
}
|
||||
|
||||
getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
+10
-4
@@ -1,16 +1,22 @@
|
||||
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsObject, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export class CreateOrganizationInput extends RefreshApiKeyInput {
|
||||
@IsString()
|
||||
@Length(1)
|
||||
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@ApiPropertyOptional({ type: Object })
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
@IsOptional()
|
||||
@ValidateMetadata()
|
||||
metadata?: Metadata;
|
||||
}
|
||||
|
||||
+11
-4
@@ -1,5 +1,7 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, Length } from "class-validator";
|
||||
import { IsObject, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateOrganizationInput {
|
||||
@IsString()
|
||||
@@ -8,8 +10,13 @@ export class UpdateOrganizationInput {
|
||||
@ApiPropertyOptional({ description: "Name of the organization", example: "CalTeam" })
|
||||
readonly name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional()
|
||||
readonly metadata?: string;
|
||||
@ValidateMetadata()
|
||||
metadata?: Metadata;
|
||||
}
|
||||
|
||||
+42
-10
@@ -142,6 +142,21 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
clear();
|
||||
});
|
||||
|
||||
it("should not create managed organization with string metadata", async () => {
|
||||
const suffix = randomString();
|
||||
|
||||
const body = {
|
||||
name: `organizations organizations org ${suffix}`,
|
||||
metadata: JSON.stringify({ key: "value" }),
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${managerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create managed organization", async () => {
|
||||
const suffix = randomString();
|
||||
|
||||
@@ -298,31 +313,48 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update managed organization ", async () => {
|
||||
const suffix = randomString();
|
||||
|
||||
const newOrgName = `new organizations organizations org ${suffix}`;
|
||||
it("should not update managed organization with string metadata", async () => {
|
||||
const body = {
|
||||
metadata: JSON.stringify({ key: "value" }),
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send({
|
||||
name: newOrgName,
|
||||
} satisfies UpdateOrganizationInput)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should update managed organization ", async () => {
|
||||
const name = `new organizations organizations org ${randomString()}`;
|
||||
const metadata = {
|
||||
updatedKey: "updatedValue",
|
||||
};
|
||||
const body: UpdateOrganizationInput = {
|
||||
name,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrg = responseBody.data;
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.name).toEqual(newOrgName);
|
||||
expect(responseManagedOrg?.name).toEqual(name);
|
||||
expect(responseManagedOrg?.metadata).toEqual(metadata);
|
||||
|
||||
const managedOrgInDb =
|
||||
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg.id);
|
||||
expect(managedOrgInDb).toBeDefined();
|
||||
expect(managedOrgInDb?.name).toEqual(newOrgName);
|
||||
expect(managedOrgInDb?.name).toEqual(name);
|
||||
expect(managedOrgInDb?.metadata).toEqual(metadata);
|
||||
|
||||
managedOrg = { ...managedOrg, name: newOrgName };
|
||||
managedOrg = { ...managedOrg, name, metadata };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-5
@@ -1,7 +1,9 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { Expose, Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, Length, ValidateNested } from "class-validator";
|
||||
|
||||
import { Metadata } from "@calcom/platform-types";
|
||||
|
||||
export class ManagedOrganizationOutput {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@@ -14,12 +16,20 @@ export class ManagedOrganizationOutput {
|
||||
@ApiProperty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@ApiPropertyOptional({ type: Object })
|
||||
@Type(() => Object)
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
@Transform(
|
||||
// note(Lauris): added this transform because without it metadata is removed for some reason
|
||||
({ obj }: { obj: { metadata: Metadata | null | undefined } }) => {
|
||||
return obj.metadata || undefined;
|
||||
}
|
||||
)
|
||||
metadata?: Metadata;
|
||||
}
|
||||
|
||||
export class ManagedOrganizationWithApiKeyOutput extends ManagedOrganizationOutput {
|
||||
|
||||
+2
-5
@@ -1,15 +1,12 @@
|
||||
import { ManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/managed-organization.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsOutputService {
|
||||
getOutputManagedOrganization(managedOrganization: Team): ManagedOrganizationOutput {
|
||||
return {
|
||||
id: managedOrganization.id,
|
||||
name: managedOrganization.name,
|
||||
metadata: managedOrganization.metadata ? JSON.parse(managedOrganization.metadata as string) : {},
|
||||
};
|
||||
return plainToClass(ManagedOrganizationOutput, managedOrganization, { strategy: "excludeAll" });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ export class ManagedOrganizationsService {
|
||||
...organizationData,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
metadata: JSON.stringify(organizationData.metadata || {}),
|
||||
metadata: organizationData.metadata,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+5
@@ -575,11 +575,15 @@ describe("Organizations Team Endpoints", () => {
|
||||
|
||||
it("should create first oAuth client team", async () => {
|
||||
const teamName = `organizations-teams-platform-api-team1-${randomString()}`;
|
||||
const teamMetadata = {
|
||||
key: `value ${randomString()}`,
|
||||
};
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient1.id)
|
||||
.send({
|
||||
name: teamName,
|
||||
metadata: teamMetadata,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
@@ -587,6 +591,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
team1 = responseBody.data;
|
||||
expect(team1.name).toEqual(teamName);
|
||||
expect(team1.metadata).toEqual(teamMetadata);
|
||||
expect(team1.parentId).toEqual(org.id);
|
||||
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id);
|
||||
|
||||
@@ -73,9 +73,12 @@ describe("Teams endpoint", () => {
|
||||
});
|
||||
|
||||
describe("User has membership in created team", () => {
|
||||
it("should create a team", async () => {
|
||||
it("should create first team", async () => {
|
||||
const body: CreateTeamInput = {
|
||||
name: `teams-dog-${randomString()}`,
|
||||
metadata: {
|
||||
teamKey: "teamValue",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -90,13 +93,30 @@ describe("Teams endpoint", () => {
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.id).toBeDefined();
|
||||
expect(responseData.name).toEqual(body.name);
|
||||
expect(responseData.metadata).toEqual(body.metadata);
|
||||
team1 = responseData;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team", async () => {
|
||||
it("should not create a team with string metadata", async () => {
|
||||
const body = {
|
||||
name: `teams-dog-${randomString()}`,
|
||||
metadata: JSON.stringify({
|
||||
teamKey: "teamValue",
|
||||
}),
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/teams")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create second team", async () => {
|
||||
const body: CreateTeamInput = {
|
||||
name: `teams-cats-${randomString()}`,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -111,6 +131,7 @@ describe("Teams endpoint", () => {
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.id).toBeDefined();
|
||||
expect(responseData.name).toEqual(body.name);
|
||||
expect(responseData.metadata).toEqual(body.metadata);
|
||||
team2 = responseData;
|
||||
});
|
||||
});
|
||||
@@ -139,16 +160,21 @@ describe("Teams endpoint", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
expect(responseBody.data[0].id).toBeDefined();
|
||||
expect(responseBody.data[0].name).toEqual(team1.name);
|
||||
expect(responseBody.data[1].id).toBeDefined();
|
||||
expect(responseBody.data[1].name).toEqual(team2.name);
|
||||
|
||||
const responseTeam1 = responseBody.data.find((team) => team.id === team1.id);
|
||||
const responseTeam2 = responseBody.data.find((team) => team.id === team2.id);
|
||||
|
||||
expect(responseTeam1).toBeDefined();
|
||||
expect(responseTeam2).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update a team", async () => {
|
||||
const body: UpdateTeamDto = {
|
||||
name: `teams-dogs-shepherds-${randomString()}`,
|
||||
metadata: {
|
||||
teamKey: `teamValue ${randomString()}`,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -162,6 +188,7 @@ describe("Teams endpoint", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(body.name);
|
||||
expect(responseBody.data.metadata).toEqual(body.metadata);
|
||||
team1 = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export class CreateTeamInput {
|
||||
@IsString()
|
||||
@@ -56,10 +58,15 @@ export class CreateTeamInput {
|
||||
@ApiPropertyOptional()
|
||||
readonly hideBookATeamMember?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional()
|
||||
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
|
||||
@ValidateMetadata()
|
||||
metadata?: Metadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateTeamDto {
|
||||
@IsString()
|
||||
@@ -56,10 +58,15 @@ export class UpdateTeamDto {
|
||||
@ApiPropertyOptional()
|
||||
readonly hideBookATeamMember?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional()
|
||||
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
|
||||
@ValidateMetadata()
|
||||
metadata?: Metadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -177,6 +177,33 @@ export class UsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findManagedUsersByOAuthClientIdAndEmails(
|
||||
oauthClientId: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
oAuthEmails?: string[]
|
||||
) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
platformOAuthClients: {
|
||||
some: {
|
||||
id: oauthClientId,
|
||||
},
|
||||
},
|
||||
isPlatformManaged: true,
|
||||
...(oAuthEmails && oAuthEmails.length > 0
|
||||
? {
|
||||
email: {
|
||||
in: oAuthEmails,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
take: limit,
|
||||
skip: cursor,
|
||||
});
|
||||
}
|
||||
|
||||
async update(userId: number, updateData: UpdateManagedUserInput) {
|
||||
this.formatInput(updateData);
|
||||
|
||||
|
||||
@@ -85,6 +85,29 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "emails",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.",
|
||||
"example": "?emails=email1@example.com,email2@example.com",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4260,6 +4283,47 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/bookings/{bookingUid}/calendar-links": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getCalendarLinks",
|
||||
"summary": "Get 'Add to Calendar' links for a booking",
|
||||
"description": "Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
"in": "header",
|
||||
"description": "Must be set to `2024-08-13`",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CalendarLinksOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Bookings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -17803,6 +17867,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CalendarLink": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "The label of the calendar link"
|
||||
},
|
||||
"link": {
|
||||
"type": "string",
|
||||
"description": "The link to the calendar"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"link"
|
||||
]
|
||||
},
|
||||
"CalendarLinksOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "The status of the request, always 'success' for successful responses",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Calendar links for the booking",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CalendarLink"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateTeamMembershipInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Environment } from "@/env";
|
||||
import "dotenv/config";
|
||||
|
||||
const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
API_URL: "http://localhost",
|
||||
@@ -22,6 +23,7 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000,
|
||||
IS_TEAM_BILLING_ENABLED: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
process.env = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function randomString() {
|
||||
return uuidv4().replace(/-/g, "");
|
||||
export function randomString(length?: number) {
|
||||
const string = uuidv4().replace(/-/g, "");
|
||||
return length ? string.substring(0, length) : string;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"@calcom/platform-enums": ["../../../packages/platform/enums/index.ts"],
|
||||
"@calcom/platform-libraries/event-types": ["../../../packages/platform/libraries/event-types.ts"],
|
||||
"@calcom/platform-libraries/slots": ["../../../packages/platform/libraries/slots.ts"],
|
||||
"@calcom/platform-libraries/emails": ["../../../packages/platform/libraries/emails.ts"]
|
||||
"@calcom/platform-libraries/emails": ["../../../packages/platform/libraries/emails.ts"],
|
||||
"@calcom/platform-libraries/schedules": ["../../../packages/platform/libraries/schedules.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -9,4 +9,8 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
const ServerPage = async () => {
|
||||
return <Page />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -9,4 +9,8 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Troubleshoot;
|
||||
const ServerPage = async () => {
|
||||
return <Troubleshoot />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -8,4 +8,8 @@ export const generateMetadata = async () =>
|
||||
() => ""
|
||||
);
|
||||
|
||||
export default LegacyPage;
|
||||
const ServerPage = async () => {
|
||||
return <LegacyPage />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import { parseUrlFormData } from "app/api/parseRequestData";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -8,7 +8,7 @@ import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClie
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { client_id, client_secret, grant_type } = await parseRequestData(req);
|
||||
const { client_id, client_secret, grant_type } = await parseUrlFormData(req);
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import { parseUrlFormData } from "app/api/parseRequestData";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -8,8 +8,7 @@ import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClie
|
||||
import type { OAuthTokenPayload } from "@calcom/types/oauth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { code, client_id, client_secret, grant_type, redirect_uri } = await parseRequestData(req);
|
||||
|
||||
const { code, client_id, client_secret, grant_type, redirect_uri } = await parseUrlFormData(req);
|
||||
if (grant_type !== "authorization_code") {
|
||||
return NextResponse.json({ message: "grant_type invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
@@ -19,12 +19,11 @@ vi.mock("next/server", () => ({
|
||||
|
||||
describe("defaultResponderForAppDir", () => {
|
||||
it("should return a JSON response when handler resolves with a result", async () => {
|
||||
const f = vi.fn().mockResolvedValue({ success: true });
|
||||
const f = vi.fn().mockResolvedValue(NextResponse.json({ success: true }));
|
||||
const req = { method: "GET", url: "/api/test" } as unknown as NextRequest;
|
||||
const params = {};
|
||||
|
||||
const response = await defaultResponderForAppDir(f)(req, { params });
|
||||
console.log(response);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
|
||||
ok = true;
|
||||
if (result) {
|
||||
return result instanceof Response ? result : NextResponse.json(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return NextResponse.json({});
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export async function parseRequestData(req: NextRequest): Promise<Record<string, any>> {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch (error) {
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
// Handle form data requests
|
||||
if (
|
||||
contentType.includes("application/x-www-form-urlencoded") ||
|
||||
contentType.includes("multipart/form-data")
|
||||
) {
|
||||
const formData = await req.formData();
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
throw new Error(`Unsupported content type: ${contentType}`);
|
||||
const log = logger.getSubLogger({ prefix: ["[parseRequestData]"] });
|
||||
|
||||
export async function parseUrlFormData(req: NextRequest): Promise<Record<string, any>> {
|
||||
try {
|
||||
// Read raw text body (because Next.js does NOT parse x-www-form-urlencoded automatically)
|
||||
const rawBody = await req.text();
|
||||
const params = new URLSearchParams(rawBody);
|
||||
return Object.fromEntries(params);
|
||||
} catch (e) {
|
||||
log.error(`Invalid Url Form Data: ${e} from path ${req.nextUrl}`);
|
||||
throw new HttpError({ statusCode: 400, message: "Bad Request (Invalid Url Form Data)" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMultiFormData(req: NextRequest): Promise<Record<string, any>> {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
return Object.fromEntries(formData.entries());
|
||||
} catch (e) {
|
||||
log.error(`Invalid Multi Form Data: ${e} from path ${req.nextUrl}`);
|
||||
throw new HttpError({ statusCode: 400, message: "Bad Request (Invalid Multi Form Data)" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseRequestData(req: NextRequest): Promise<Record<string, any>> {
|
||||
const contentType = req.headers.get("content-type") ?? "application/json";
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch (e) {
|
||||
log.error(`Invalid JSON: ${e} from path ${req.nextUrl}`);
|
||||
throw new HttpError({ statusCode: 400, message: "Bad Request (Invalid JSON)" });
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
return await parseUrlFormData(req);
|
||||
}
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
return await parseMultiFormData(req);
|
||||
}
|
||||
|
||||
log.error(`Unsupported content type: ${contentType} from path ${req.nextUrl}`);
|
||||
throw new HttpError({ statusCode: 415, message: `Unsupported Content-Type. Expected ${contentType}` });
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { createEvent } from "ics";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { RRule } from "rrule";
|
||||
import { Toaster } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -28,6 +26,7 @@ import {
|
||||
SystemField,
|
||||
TITLE_FIELD,
|
||||
} from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import {
|
||||
formatToLocalizedDate,
|
||||
@@ -133,9 +132,13 @@ export default function Success(props: PageProps) {
|
||||
) {
|
||||
rescheduleLocation = bookingInfo.responses.location.optionValue;
|
||||
}
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
bookingInfo?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
|
||||
const parsedBookingMetadata = bookingMetadataSchema.parse(bookingInfo?.metadata || {});
|
||||
const bookingWithParsedMetadata = {
|
||||
...bookingInfo,
|
||||
metadata: parsedBookingMetadata,
|
||||
};
|
||||
const locationVideoCallUrl = bookingWithParsedMetadata.metadata?.videoCallUrl;
|
||||
|
||||
const status = bookingInfo?.status;
|
||||
const reschedule = bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
@@ -152,6 +155,21 @@ export default function Success(props: PageProps) {
|
||||
const isHost = props.isLoggedInUserHost;
|
||||
|
||||
const [date, setDate] = useState(dayjs.utc(bookingInfo.startTime));
|
||||
const calendarLinks = getCalendarLinks({
|
||||
booking: bookingWithParsedMetadata,
|
||||
eventType: eventType,
|
||||
t,
|
||||
});
|
||||
|
||||
// TODO: We could transform the JSX to just iterate over calendarLinks and render a link for each type
|
||||
const icsLink = calendarLinks.find((link) => link.id === CalendarLinkType.ICS)?.link;
|
||||
const microsoftOfficeLink = calendarLinks.find(
|
||||
(link) => link.id === CalendarLinkType.MICROSOFT_OFFICE
|
||||
)?.link;
|
||||
const microsoftOutlookLink = calendarLinks.find(
|
||||
(link) => link.id === CalendarLinkType.MICROSOFT_OUTLOOK
|
||||
)?.link;
|
||||
const googleCalendarLink = calendarLinks.find((link) => link.id === CalendarLinkType.GOOGLE_CALENDAR)?.link;
|
||||
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const isEmbed = useIsEmbed();
|
||||
@@ -272,37 +290,6 @@ export default function Success(props: PageProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function eventLink(): string {
|
||||
const optional: { location?: string } = {};
|
||||
if (locationVideoCallUrl) {
|
||||
optional["location"] = locationVideoCallUrl;
|
||||
}
|
||||
|
||||
const event = createEvent({
|
||||
start: [
|
||||
date.toDate().getUTCFullYear(),
|
||||
(date.toDate().getUTCMonth() as number) + 1,
|
||||
date.toDate().getUTCDate(),
|
||||
date.toDate().getUTCHours(),
|
||||
date.toDate().getUTCMinutes(),
|
||||
],
|
||||
startInputType: "utc",
|
||||
title: eventName,
|
||||
description: eventType.description ? eventType.description : undefined,
|
||||
/** formatted to required type of description ^ */
|
||||
duration: {
|
||||
minutes: calculatedDuration,
|
||||
},
|
||||
...optional,
|
||||
});
|
||||
|
||||
if (event.error) {
|
||||
throw event.error;
|
||||
}
|
||||
|
||||
return encodeURIComponent(event.value ? event.value : false);
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||
const titlePrefix = isRoundRobin ? "round_robin_" : "";
|
||||
@@ -322,12 +309,12 @@ export default function Success(props: PageProps) {
|
||||
const attendee = bookingInfo.attendees[0]?.name || bookingInfo.attendees[0]?.email || "Nameless";
|
||||
const host = bookingInfo.user.name || bookingInfo.user.email;
|
||||
if (isHost) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendees${titleSuffix}`, {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
user: attendee,
|
||||
});
|
||||
}
|
||||
if (isAttendee) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendees${titleSuffix}`, {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
user: host,
|
||||
});
|
||||
}
|
||||
@@ -336,7 +323,7 @@ export default function Success(props: PageProps) {
|
||||
attendee,
|
||||
});
|
||||
}
|
||||
return t(`emailed_host_and_attendees${titleSuffix}`);
|
||||
return t(`emailed_host_and_attendee${titleSuffix}`);
|
||||
}
|
||||
|
||||
// This is a weird case where the same route can be opened in booking flow as a success page or as a booking detail page from the app
|
||||
@@ -805,118 +792,67 @@ export default function Success(props: PageProps) {
|
||||
{t("add_to_calendar")}
|
||||
</span>
|
||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||
<Link
|
||||
href={`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
eventType.description
|
||||
}${
|
||||
typeof locationVideoCallUrl === "string"
|
||||
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||
: ""
|
||||
}${
|
||||
eventType.recurringEvent
|
||||
? `&recur=${encodeURIComponent(
|
||||
new RRule(eventType.recurringEvent).toString()
|
||||
)}`
|
||||
: ""
|
||||
}`}
|
||||
className="text-default border-subtle h-10 w-10 rounded-sm border px-3 py-2 ltr:mr-2 rtl:ml-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
|
||||
eventType.description
|
||||
}&enddt=${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||
.utc()
|
||||
.format()}&subject=${eventName}`
|
||||
) +
|
||||
(locationVideoCallUrl
|
||||
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||
: "")
|
||||
}
|
||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
encodeURI(
|
||||
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
|
||||
eventType.description
|
||||
}&enddt=${date
|
||||
.add(calculatedDuration, "minute")
|
||||
.utc()
|
||||
.format()}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${date
|
||||
.utc()
|
||||
.format()}&subject=${eventName}`
|
||||
) +
|
||||
(locationVideoCallUrl
|
||||
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
|
||||
: "")
|
||||
}
|
||||
className="text-default border-subtle mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</Link>
|
||||
{(() => {
|
||||
try {
|
||||
const calendarLink = eventLink();
|
||||
if (!calendarLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`data:text/calendar,${calendarLink}`}
|
||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
download={`${eventType.title}.ics`}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate .ics download link:", error);
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
{googleCalendarLink && (
|
||||
<Link
|
||||
href={googleCalendarLink}
|
||||
className="text-default border-subtle h-10 w-10 rounded-sm border px-3 py-2 ltr:mr-2 rtl:ml-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Google</title>
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{microsoftOutlookLink && (
|
||||
<Link
|
||||
href={microsoftOutlookLink}
|
||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Outlook</title>
|
||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{microsoftOfficeLink && (
|
||||
<Link
|
||||
href={microsoftOfficeLink}
|
||||
className="text-default border-subtle mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
target="_blank">
|
||||
<svg
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<title>Microsoft Office</title>
|
||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{icsLink && (
|
||||
<Link
|
||||
href={icsLink}
|
||||
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
|
||||
download={`${eventType.title}.ics`}>
|
||||
<svg
|
||||
version="1.1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 1000"
|
||||
className="-mt-1.5 mr-1 inline-block h-4 w-4">
|
||||
<title>{t("other")}</title>
|
||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.0.15",
|
||||
"version": "5.0.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -43,17 +43,17 @@ test.describe("OAuth Provider", () => {
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
// request token with authorization code
|
||||
const tokenForm = new URLSearchParams();
|
||||
tokenForm.append("code", code ?? "");
|
||||
tokenForm.append("client_id", client.clientId);
|
||||
tokenForm.append("client_secret", client.orginalSecret);
|
||||
tokenForm.append("grant_type", "authorization_code");
|
||||
tokenForm.append("redirect_uri", client.redirectUri);
|
||||
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
client_id: client.clientId,
|
||||
client_secret: client.orginalSecret,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: client.redirectUri,
|
||||
}),
|
||||
body: tokenForm.toString(),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,16 +74,16 @@ test.describe("OAuth Provider", () => {
|
||||
expect(meData.username.startsWith("test user")).toBe(true);
|
||||
|
||||
// request new token with refresh token
|
||||
const refreshTokenForm = new URLSearchParams();
|
||||
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
|
||||
refreshTokenForm.append("client_id", client.clientId);
|
||||
refreshTokenForm.append("client_secret", client.orginalSecret);
|
||||
refreshTokenForm.append("grant_type", "refresh_token");
|
||||
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
|
||||
body: JSON.stringify({
|
||||
refresh_token: tokenData.refresh_token,
|
||||
client_id: client.clientId,
|
||||
client_secret: client.orginalSecret,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
body: refreshTokenForm.toString(),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,17 +126,17 @@ test.describe("OAuth Provider", () => {
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
// request token with authorization code
|
||||
const tokenForm = new URLSearchParams();
|
||||
tokenForm.append("code", code ?? "");
|
||||
tokenForm.append("client_id", client.clientId);
|
||||
tokenForm.append("client_secret", client.orginalSecret);
|
||||
tokenForm.append("grant_type", "authorization_code");
|
||||
tokenForm.append("redirect_uri", client.redirectUri);
|
||||
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
client_id: client.clientId,
|
||||
client_secret: client.orginalSecret,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: client.redirectUri,
|
||||
}),
|
||||
body: tokenForm.toString(),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,16 +157,16 @@ test.describe("OAuth Provider", () => {
|
||||
expect(meData.username).toEqual(`user-id-${user.id}'s Team`);
|
||||
|
||||
// request new token with refresh token
|
||||
const refreshTokenForm = new URLSearchParams();
|
||||
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
|
||||
refreshTokenForm.append("client_id", client.clientId);
|
||||
refreshTokenForm.append("client_secret", client.orginalSecret);
|
||||
refreshTokenForm.append("grant_type", "refresh_token");
|
||||
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
|
||||
body: JSON.stringify({
|
||||
refresh_token: tokenData.refresh_token,
|
||||
client_id: client.clientId,
|
||||
client_secret: client.orginalSecret,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
body: refreshTokenForm.toString(),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2625
-599
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -202,7 +202,16 @@
|
||||
"platform/atoms/conferencing-apps"
|
||||
]
|
||||
},
|
||||
"platform/hooks",
|
||||
{
|
||||
"group": "Hooks",
|
||||
"icon": "brackets-curly",
|
||||
"pages": [
|
||||
"platform/bookings-hooks",
|
||||
"platform/event-types-hooks",
|
||||
"platform/calendars-hooks",
|
||||
"platform/user-or-team-related-hooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"icon": "book",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: Bookings hooks
|
||||
description: Overview of all the hooks associated with bookings.
|
||||
---
|
||||
|
||||
Optimize your development workflow using our custom hooks. Our custom hooks are designed to simplify the integration of the Cal.com API into your products. With these hooks, you can effortlessly manage state, handle side effects, and optimize performance, allowing you to focus on building exceptional user experiences without the hassle of complex API interactions.
|
||||
|
||||
Below is a list of hooks you can find that are associated with bookings:
|
||||
|
||||
### 1. `useBookings`
|
||||
|
||||
The useBookings hook returns an array of all the booking data, allowing you to filter bookings by status, such as ***upcoming,*** ***recurring***, ***past,*** ***cancelled,*** or ***unconfirmed.*** It also supports pagination through the take and skip parameters. Additionally, various properties can be passed to useBookings, which can be referenced in the [following](https://github.com/calcom/cal.com/blob/307b2946a2cb6dcdb32dbee6c4f448e8a01c4285/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts#L32) documentation for detailed information.
|
||||
|
||||
Below code snippet shows how to use the useBookings hook to fetch bookings for a specific user.
|
||||
|
||||
```js
|
||||
import { useBookings } from "@calcom/atoms";
|
||||
|
||||
export default function Bookings() {
|
||||
const { isLoading: isLoadingUpcomingBookings, data: upcomingBookings } = useBookings({
|
||||
take: 50,
|
||||
skip: 0,
|
||||
status: ["upcoming"],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Upcoming bookings</h1>
|
||||
{isLoadingUpcomingBookings && <p>Loading...</p>}
|
||||
{!isLoadingUpcomingBookings && !upcomingBookings && <p>No upcoming bookings found</p>}
|
||||
{!isLoading &&
|
||||
upcomingBookings &&
|
||||
(Boolean(upcomingBookings?.length)) &&
|
||||
[...upcomingBookings].map((booking) => {
|
||||
return (
|
||||
<div key={booking.id}><h1>{booking.title}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useBooking`
|
||||
|
||||
The useBooking hook returns detailed information about a single booking. Simply provide the booking's unique identifier ***(bookingUid)*** as a parameter to fetch its associated data.
|
||||
|
||||
Below code snippet shows how to use the useBooking hook to fetch a specific booking for a user.
|
||||
|
||||
```js
|
||||
import { useBooking } from "@calcom/atoms";
|
||||
|
||||
export default function Booking() {
|
||||
const { isLoading: isLoadingBooking, data: booking } = useBooking("nChHoxEm1GXVPzi7TNAuWc");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingBooking && <p>Loading...</p>}
|
||||
{!isLoadingBooking && !booking && <p>No booking found</p>}
|
||||
{!isLoading &&
|
||||
!!booking &&
|
||||
return (
|
||||
<div>Title: {booking.title}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `useCancelBooking`
|
||||
|
||||
The useCancelBooking hook allows you to cancel a booking. This hook returns a mutation function that handles the cancellation process. To cancel a booking, you'll need to provide at least the booking's unique identifier ***(uid).*** While the uid is the only mandatory parameter, you can enhance the cancellation process by including optional parameters such as ***cancellationReason*** to specify why the booking is being canceled, and ***allRemainingBookings*** to specify whether to cancel all future recurring bookings.
|
||||
|
||||
Below code snippet shows how to use the useCancelBooking hook to cancel a booking for a user.
|
||||
|
||||
```js
|
||||
import { useCancelBooking } from "@calcom/atoms";
|
||||
|
||||
export default function CancelBooking() {
|
||||
const { mutate: cancelBooking } = useCancelBooking({
|
||||
onSuccess: () => {
|
||||
console.log("Booking canceled successfully!")
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
cancelBooking({
|
||||
uid: "6L6ADNpVHwLX25V8wXKsBr",
|
||||
cancellationReason: "User request",
|
||||
allRemainingBookings: true,
|
||||
})
|
||||
}}>
|
||||
Cancel booking
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Calendars hooks
|
||||
description: Overview of all the hooks associated with calendars.
|
||||
---
|
||||
|
||||
### 1. `useConnectedCalendars`
|
||||
|
||||
The useConnectedCalendars returns an object containing all connected calendars of a user, and the destination calendar. It is useful in managing multiple calendar integrations. Each connected calendar entry contains essential properties such as the credentialId, externalId and integration which should be an object, which can be used in the hooks to add and remove selected calendars.
|
||||
|
||||
Below code snippet shows how to use the useConnectedCalendars hook to fetch calendars for a user.
|
||||
|
||||
```js
|
||||
import { useConnectedCalendars } from "@calcom/atoms";
|
||||
|
||||
export default function ConnectedCalendars() {
|
||||
const { isLoading: isLoadingConnectedCalendars, data: userCalendars } = useConnectedCalendars({});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingConnectedCalendars && <p>Loading...</p>}
|
||||
{!isLoadingConnectedCalendars && !userCalendars && <p>No connected calendars</p>}
|
||||
{!isLoadingConnectedCalendars &&
|
||||
userCalendars &&
|
||||
(Boolean(userCalendars?connectedCalendars?.length)) &&
|
||||
userCalendars?connectedCalendars?.map((calendar) => {
|
||||
return (
|
||||
<div key={calendar.credentialId}><h1>Calendar type: {calendar.integration.name}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useAddSelectedCalendar`
|
||||
|
||||
The useAddSelectedCalendar lets you add a calendar to check for conflicts to prevent double bookings. This hook returns a mutation function that handles the addition process. The mutation function accepts an object with the following properties: ***credentialId*** which is the credential id of the calendar to add, ***integration*** which is the name of the integration, and ***externalId*** which is the external id of the calendar. In order to obtain those credentials, you can use the ***useConnectedCalendars*** hook.
|
||||
|
||||
Below code snippet shows how to use the useAddSelectedCalendar hook to add a specific calendar to check for conflicts.
|
||||
|
||||
```js
|
||||
import { useAddSelectedCalendar } from "@calcom/atoms";
|
||||
|
||||
export default function AddSelectedCalendar() {
|
||||
const { mutate: addSelectedCalendar } = useAddSelectedCalendar({
|
||||
onSuccess: () => {
|
||||
console.log("Selected calendar added successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error adding selected calendar");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
addSelectedCalendar({
|
||||
credentialId: 123,
|
||||
integration: "google_calendar",
|
||||
externalId: "613f23c27149a2796f2c155bfe79203a135@group.calendar.google.com",
|
||||
})
|
||||
}}>
|
||||
Add selected calendar
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `useRemoveSelectedCalendar`
|
||||
|
||||
The useRemoveSelectedCalendar lets you remove a calendar to check for conflicts to prevent double bookings. This hook returns a mutation function that handles the addition process. The mutation function accepts an object with the following properties: ***credentialId*** which is the credential id of the calendar to add, ***integration*** which is the name of the integration, and ***externalId*** which is the external id of the calendar. In order to obtain those credentials, you can use the ***useConnectedCalendars*** hook.
|
||||
|
||||
Below code snippet shows how to use the useRemoveSelectedCalendar hook to remove a specific calendar to check for conflicts.
|
||||
|
||||
```js
|
||||
import { useRemoveSelectedCalendar } from "@calcom/atoms";
|
||||
|
||||
export default function RemoveSelectedCalendar() {
|
||||
const { mutate: removeSelectedCalendar } = useRemoveSelectedCalendar({
|
||||
onSuccess: () => {
|
||||
console.log("Selected calendar removed successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error removing selected calendar");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
removeSelectedCalendar({
|
||||
credentialId: 987,
|
||||
integration: "office365_calendar",
|
||||
externalId: "AQMkADAwATM3AADfEpy7w2EpEOi_xwYa7A_cQcACgtJE7RnHEeyisVq2Eq2ENs2gAAAhFDAAAA",
|
||||
})
|
||||
}}>
|
||||
Remove selected calendar
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `useDeleteCalendarCredentials`
|
||||
|
||||
The useDeleteCalendarCredentials hook allows you to delete a users calendar credentials. This hook returns a mutation function which handles the deletion process. The mutation function accepts an object with the following properties: ***id*** which is the credential id, and ***calendar*** is the name of the calendar which can be either google, office365 or apple.
|
||||
|
||||
Below code snippet shows how to use the useDeleteCalendarCredentials hook to manage deletion of calendar credentials.
|
||||
|
||||
```js
|
||||
import { useDeleteCalendarCredentials } from "@calcom/atoms";
|
||||
|
||||
export default function DeleteUserCalendar() {
|
||||
const { mutate: deleteCalendarCredentials } = useDeleteCalendarCredentials({
|
||||
onSuccess: () => {
|
||||
console.log("Calendar credentials deleted successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error deleting calendar credentials");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={async () => {
|
||||
await deleteCalendarCredentials({
|
||||
calendar: "google",
|
||||
id: 123,
|
||||
})
|
||||
}}>
|
||||
Delete calendar
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Event types hooks
|
||||
description: Overview of all the hooks associated with event types.
|
||||
---
|
||||
|
||||
<Note>In order to create a team event type, you need to have an Essentials plan or above. Additionally, you also need to be a team admin or owner.</Note>
|
||||
|
||||
### 1. `useEventTypes`
|
||||
|
||||
The useEventTypes returns all event types associated with a user. Simply provide a username to fetch all their available event types. This hook is useful when you need to display or manage a user's entire collection of event configurations.
|
||||
|
||||
Below code snippet shows how to use the useEventTypes hook to fetch the event types of a specific user.
|
||||
|
||||
```js
|
||||
import { useEventTypes } from "@calcom/atoms";
|
||||
|
||||
export default function EventTypes({ username }: { username: string }) {
|
||||
const { isLoading: isLoadingEvents, data: eventTypes, refetch } = useEventTypes(username);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingEvents && <p>Loading...</p>}
|
||||
{!isLoadingEvents && !eventTypes && <p>No event types found</p>}
|
||||
{!isLoadingEvents &&
|
||||
eventTypes &&
|
||||
(Boolean(eventTypes?.length)) &&
|
||||
eventTypes?.map((event) => {
|
||||
return (
|
||||
<div key={event.id}><h1>Event: {event.slug}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useTeamEventTypes`
|
||||
|
||||
The useTeamEventTypes returns all event types associated with a team. Simply provide the team id to fetch all their available event types. This hook is useful when you need to display or manage a team's entire collection of event configurations.
|
||||
|
||||
Below code snippet shows how to use the useTeamEventTypes hook to fetch the event types of a team.
|
||||
|
||||
```js
|
||||
import { useTeamEventTypes } from "@calcom/atoms";
|
||||
|
||||
export default function TeamEventTypes({ id }: { id: number }) {
|
||||
const { isLoading: isLoadingTeamEvents, data: teamEventTypes, refetch: refetchTeamEvents } = useTeamEventTypes(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingTeamEvents && <p>Loading...</p>}
|
||||
{!isLoadingTeamEvents && !teamEventTypes && <p>No team events found</p>}
|
||||
{!isLoadingTeamEvents &&
|
||||
teamEventTypes &&
|
||||
(Boolean(teamEventTypes?.length)) &&
|
||||
teamEventTypes?.map((event) => {
|
||||
return (
|
||||
<div key={event.id}><h1>Team event: {event.slug}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `useEventTypeById`
|
||||
|
||||
The useEventTypeById returns data for a specific event type, provided you pass in the correct event type id. This hook is useful when you need to display or manage a specific event type.
|
||||
|
||||
Below code snippet shows how to use the useEventTypeById hook to fetch a specific event type.
|
||||
|
||||
```js
|
||||
import { useEventTypeById } from "@calcom/atoms";
|
||||
|
||||
export default function EventType({ id }: { id: number }) {
|
||||
const { isLoading: isLoadingEventType, data: eventType } = useEventTypeById(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingEventType && <p>Loading...</p>}
|
||||
{!isLoadingEventType && !eventType && <p>No event type found</p>}
|
||||
{!isLoadingEventType &&
|
||||
!!eventType &&
|
||||
return (
|
||||
<div>Title: {eventType.slug}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `useCreateEventType`
|
||||
|
||||
The useCreateEventType hook allows you to create a new event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, and ***description*** which is the description of the event.
|
||||
|
||||
Below code snippet shows how to use the useCreateEventType hook to setup an event type.
|
||||
|
||||
```js
|
||||
import { useCreateEventType } from "@calcom/atoms";
|
||||
|
||||
export default function CreateEventType() {
|
||||
const { mutate: createEventType, isPending } = useCreateEventType({
|
||||
onSuccess: () => {
|
||||
console.log("Event type created successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error creating event type");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
createEventType({
|
||||
lengthInMinutes: 15,
|
||||
title: "Coffee chat",
|
||||
slug: "coffee-chat",
|
||||
description: "Quick chat about coffee and life in general!",
|
||||
})
|
||||
}}>
|
||||
Create event type
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `useCreateTeamEventType`
|
||||
|
||||
The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team.
|
||||
|
||||
Below code snippet shows how to use the useCreateTeamEventType hook to setup a team event type.
|
||||
|
||||
```js
|
||||
import { useCreateTeamEventType } from "@calcom/atoms";
|
||||
|
||||
export default function CreateTeamEventType() {
|
||||
const { mutate: createTeamEventType, isPending } = useCreateTeamEventType({
|
||||
onSuccess: () => {
|
||||
console.log("Event type created successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error creating event type");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
createTeamEventType({
|
||||
lengthInMinutes: 30,
|
||||
title: "Daily standup",
|
||||
slug: "daily-standup",
|
||||
description: "Daily standup for the team",
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [{"userId": 1456}, {"userId": 2357}],
|
||||
teamId: 1234,
|
||||
})
|
||||
}}>
|
||||
Create team event type
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -1,306 +0,0 @@
|
||||
---
|
||||
title: Hooks
|
||||
description: Optimize your development workflow using our custom hooks.
|
||||
"icon": "brackets-curly"
|
||||
---
|
||||
|
||||
Our custom hooks are designed to simplify the integration of the Cal.com API into your products. With these hooks, you can effortlessly manage state, handle side effects, and optimize performance, allowing you to focus on building exceptional user experiences without the hassle of complex API interactions.
|
||||
|
||||
Below are hooks you can use to interact with the Cal.com API:
|
||||
|
||||
### 1. `useBookings`
|
||||
|
||||
The useBookings hook returns an array of all the booking data, allowing you to filter bookings by status, such as ***upcoming,*** ***recurring***, ***past,*** ***cancelled,*** or ***unconfirmed.*** It also supports pagination through the take and skip parameters. Additionally, various properties can be passed to useBookings, which can be referenced in the [following](https://github.com/calcom/cal.com/blob/307b2946a2cb6dcdb32dbee6c4f448e8a01c4285/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts#L32) documentation for detailed information.
|
||||
|
||||
Below code snippet shows how to use the useBookings hook to fetch bookings for a specific user.
|
||||
|
||||
```js
|
||||
import { useBookings } from "@calcom/atoms";
|
||||
|
||||
export default function Bookings() {
|
||||
const { isLoading: isLoadingUpcomingBookings, data: upcomingBookings } = useBookings({
|
||||
take: 50,
|
||||
skip: 0,
|
||||
status: ["upcoming"],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Upcoming bookings</h1>
|
||||
{isLoadingUpcomingBookings && <p>Loading...</p>}
|
||||
{!isLoadingUpcomingBookings && !upcomingBookings && <p>No upcoming bookings found</p>}
|
||||
{!isLoading &&
|
||||
upcomingBookings &&
|
||||
(Boolean(upcomingBookings?.length)) &&
|
||||
[...upcomingBookings].map((booking) => {
|
||||
return (
|
||||
<div key={booking.id}><h1>{booking.title}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useBooking`
|
||||
|
||||
The useBooking hook returns detailed information about a single booking. Simply provide the booking's unique identifier ***(bookingUid)*** as a parameter to fetch its associated data.
|
||||
|
||||
Below code snippet shows how to use the useBooking hook to fetch a specific booking for a user.
|
||||
|
||||
```js
|
||||
import { useBooking } from "@calcom/atoms";
|
||||
|
||||
export default function Booking() {
|
||||
const { isLoading: isLoadingBooking, data: booking } = useBooking("nChHoxEm1GXVPzi7TNAuWc");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingBooking && <p>Loading...</p>}
|
||||
{!isLoadingBooking && !booking && <p>No booking found</p>}
|
||||
{!isLoading &&
|
||||
!!booking &&
|
||||
return (
|
||||
<div>Title: {booking.title}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `useCancelBooking`
|
||||
|
||||
The useCancelBooking hook allows you to cancel a booking. This hook returns a mutation function that handles the cancellation process. To cancel a booking, you'll need to provide at least the booking's unique identifier ***(uid).*** While the uid is the only mandatory parameter, you can enhance the cancellation process by including optional parameters such as ***cancellationReason*** to specify why the booking is being canceled, and ***allRemainingBookings*** to specify whether to cancel all future recurring bookings.
|
||||
|
||||
Below code snippet shows how to use the useCancelBooking hook to cancel a booking for a user.
|
||||
|
||||
```js
|
||||
import { useCancelBooking } from "@calcom/atoms";
|
||||
|
||||
export default function CancelBooking() {
|
||||
const { mutate: cancelBooking } = useCancelBooking({
|
||||
onSuccess: () => {
|
||||
console.log("Booking canceled successfully!")
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
cancelBooking({
|
||||
uid: "6L6ADNpVHwLX25V8wXKsBr",
|
||||
cancellationReason: "User request",
|
||||
allRemainingBookings: true,
|
||||
})
|
||||
}}>
|
||||
Cancel booking
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `useEventTypes`
|
||||
|
||||
The useEventTypes returns all event types associated with a user. Simply provide a username to fetch all their available event types. This hook is useful when you need to display or manage a user's entire collection of event configurations.
|
||||
|
||||
Below code snippet shows how to use the useEventTypes hook to fetch the event types of a specific user.
|
||||
|
||||
```js
|
||||
import { useEventTypes } from "@calcom/atoms";
|
||||
|
||||
export default function EventTypes({ username }: { username: string }) {
|
||||
const { isLoading: isLoadingEvents, data: eventTypes, refetch } = useEventTypes(username);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingEvents && <p>Loading...</p>}
|
||||
{!isLoadingEvents && !eventTypes && <p>No event types found</p>}
|
||||
{!isLoadingEvents &&
|
||||
eventTypes &&
|
||||
(Boolean(eventTypes?.length)) &&
|
||||
eventTypes?.map((event) => {
|
||||
return (
|
||||
<div key={event.id}><h1>Event: {event.slug}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `useTeamEventTypes`
|
||||
|
||||
The useTeamEventTypes returns all event types associated with a team. Simply provide the team id to fetch all their available event types. This hook is useful when you need to display or manage a team's entire collection of event configurations.
|
||||
|
||||
Below code snippet shows how to use the useTeamEventTypes hook to fetch the event types of a team.
|
||||
|
||||
```js
|
||||
import { useTeamEventTypes } from "@calcom/atoms";
|
||||
|
||||
export default function TeamEventTypes({ id }: { id: number }) {
|
||||
const { isLoading: isLoadingTeamEvents, data: teamEventTypes, refetch: refetchTeamEvents } = useTeamEventTypes(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingTeamEvents && <p>Loading...</p>}
|
||||
{!isLoadingTeamEvents && !teamEventTypes && <p>No team events found</p>}
|
||||
{!isLoadingTeamEvents &&
|
||||
teamEventTypes &&
|
||||
(Boolean(teamEventTypes?.length)) &&
|
||||
teamEventTypes?.map((event) => {
|
||||
return (
|
||||
<div key={event.id}><h1>Team event: {event.slug}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. `useEventTypeById`
|
||||
|
||||
The useEventTypeById returns data for a specific event type, provided you pass in the correct event type id. This hook is useful when you need to display or manage a specific event type.
|
||||
|
||||
Below code snippet shows how to use the useEventTypeById hook to fetch a specific event type.
|
||||
|
||||
```js
|
||||
import { useEventTypeById } from "@calcom/atoms";
|
||||
|
||||
export default function EventType({ id }: { id: number }) {
|
||||
const { isLoading: isLoadingEventType, data: eventType } = useEventTypeById(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingEventType && <p>Loading...</p>}
|
||||
{!isLoadingEventType && !eventType && <p>No event type found</p>}
|
||||
{!isLoadingEventType &&
|
||||
!!eventType &&
|
||||
return (
|
||||
<div>Title: {eventType.slug}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 7. `useConnectedCalendars`
|
||||
|
||||
The useConnectedCalendars returns an object containing all connected calendars of a user, and the destination calendar. It is useful in managing multiple calendar integrations. Each connected calendar entry contains essential properties such as the credentialId, externalId and integration which should be an object, which can be used in the hooks to add and remove selected calendars.
|
||||
|
||||
Below code snippet shows how to use the useConnectedCalendars hook to fetch calendars for a user.
|
||||
|
||||
```js
|
||||
import { useConnectedCalendars } from "@calcom/atoms";
|
||||
|
||||
export default function ConnectedCalendars() {
|
||||
const { isLoading: isLoadingConnectedCalendars, data: userCalendars } = useConnectedCalendars({});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingConnectedCalendars && <p>Loading...</p>}
|
||||
{!isLoadingConnectedCalendars && !userCalendars && <p>No connected calendars</p>}
|
||||
{!isLoadingConnectedCalendars &&
|
||||
userCalendars &&
|
||||
(Boolean(userCalendars?connectedCalendars?.length)) &&
|
||||
userCalendars?connectedCalendars?.map((calendar) => {
|
||||
return (
|
||||
<div key={calendar.credentialId}><h1>Calendar type: {calendar.integration.name}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 8. `useDeleteCalendarCredentials`
|
||||
|
||||
The useDeleteCalendarCredentials hook allows you to delete a users calendar credentials. This hook returns a mutation function which handles the deletion process. The mutation function accepts an object with the following properties: ***id*** which is the credential id, and ***calendar*** is the name of the calendar which can be either google, office365 or apple.
|
||||
|
||||
Below code snippet shows how to use the useDeleteCalendarCredentials hook to manage deletion of calendar credentials.
|
||||
|
||||
```js
|
||||
import { useDeleteCalendarCredentials } from "@calcom/atoms";
|
||||
|
||||
export default function DeleteUserCalendar() {
|
||||
const { mutate: deleteCalendarCredentials } = useDeleteCalendarCredentials({
|
||||
onSuccess: () => {
|
||||
console.log("Calendar credentials deleted successfully!");
|
||||
},
|
||||
onError: () => {
|
||||
console.log("Error deleting calendar credentials");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={async () => {
|
||||
await deleteCalendarCredentials({
|
||||
calendar: "google",
|
||||
id: 123,
|
||||
})
|
||||
}}>
|
||||
Delete calendar
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9. `useTeams`
|
||||
|
||||
The useTeams returns all teams info. This hook is useful when you need to display or manage a user's entire collection of teams.
|
||||
|
||||
Below code snippet shows how to use the useTeams hook to fetch team details.
|
||||
|
||||
```js
|
||||
import { useTeams } from "@calcom/atoms";
|
||||
|
||||
export default function UserTeams() {
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingTeams && <p>Loading...</p>}
|
||||
{!isLoadingTeams && !teams && <p>No teams found</p>}
|
||||
{!isLoadingTeams &&
|
||||
teams &&
|
||||
(Boolean(teams?.length)) &&
|
||||
teams?.map((team) => {
|
||||
return (
|
||||
<div key={team.id}><h1>Team name: {team.name}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 10. `useMe`
|
||||
|
||||
The useMe returns the current managed user's info. This hook is useful when you need to display a managed user's details.
|
||||
|
||||
Below code snippet shows how to use the useMe hook to fetch user details.
|
||||
|
||||
```js
|
||||
import { useMe } from "@calcom/atoms";
|
||||
|
||||
export default function UserDetails() {
|
||||
const { data: userData, isLoading: isLoadingUser } = useMe();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingUser && <p>Loading...</p>}
|
||||
{!isLoadingUser && !userData && <p>No user found</p>}
|
||||
{!isLoadingUser &&
|
||||
!!userData &&
|
||||
return (
|
||||
<div>Username: {userData.username}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: User / Teams hooks
|
||||
description: Overview of all the hooks associated with managed users or teams.
|
||||
---
|
||||
|
||||
### 1. `useMe`
|
||||
|
||||
The useMe returns the current managed user's info. This hook is useful when you need to display a managed user's details.
|
||||
|
||||
Below code snippet shows how to use the useMe hook to fetch user details.
|
||||
|
||||
```js
|
||||
import { useMe } from "@calcom/atoms";
|
||||
|
||||
export default function UserDetails() {
|
||||
const { data: userData, isLoading: isLoadingUser } = useMe();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingUser && <p>Loading...</p>}
|
||||
{!isLoadingUser && !userData && <p>No user found</p>}
|
||||
{!isLoadingUser &&
|
||||
!!userData &&
|
||||
return (
|
||||
<div>Username: {userData.username}</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useTeams`
|
||||
|
||||
The useTeams returns all teams info. This hook is useful when you need to display or manage a user's entire collection of teams.
|
||||
|
||||
Below code snippet shows how to use the useTeams hook to fetch team details.
|
||||
|
||||
```js
|
||||
import { useTeams } from "@calcom/atoms";
|
||||
|
||||
export default function UserTeams() {
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingTeams && <p>Loading...</p>}
|
||||
{!isLoadingTeams && !teams && <p>No teams found</p>}
|
||||
{!isLoadingTeams &&
|
||||
teams &&
|
||||
(Boolean(teams?.length)) &&
|
||||
teams?.map((team) => {
|
||||
return (
|
||||
<div key={team.id}><h1>Team name: {team.name}</h1></div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -13,7 +13,7 @@
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/ui": "*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dub": "^0.55.1",
|
||||
"dub": "^0.59.1",
|
||||
"handlebars": "^4.7.7",
|
||||
"jose": "^4.13.1",
|
||||
"lru-cache": "^9.0.3",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@@ -55,34 +54,25 @@ export function RoutingFormResponsesTable() {
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
||||
|
||||
const { sorting, updateFilter } = useDataTable();
|
||||
const { sorting, limit, offset, updateFilter } = useDataTable();
|
||||
|
||||
const { data, fetchNextPage, isFetching, isPending, hasNextPage, isLoading } =
|
||||
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
|
||||
{
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserIds,
|
||||
isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
limit: 30,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: keepPreviousData,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
const { data, isFetching, isPending, isLoading } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserIds,
|
||||
isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!isHeadersSuccess) return [];
|
||||
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
|
||||
if (!isHeadersSuccess || !data) return [];
|
||||
return data.data as RoutingFormTableRow[];
|
||||
}, [data, isHeadersSuccess]);
|
||||
|
||||
const columns = useInsightsColumns({ headers, isHeadersSuccess });
|
||||
@@ -118,7 +108,7 @@ export function RoutingFormResponsesTable() {
|
||||
}
|
||||
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
|
||||
|
||||
if ((isHeadersLoading && !headers) || ((isFetching || isLoading) && !data)) {
|
||||
if (isHeadersLoading && !headers) {
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
|
||||
@@ -128,10 +118,9 @@ export function RoutingFormResponsesTable() {
|
||||
<DataTableWrapper
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetching={isFetching}
|
||||
paginationMode="infinite"
|
||||
paginationMode="standard"
|
||||
totalRowCount={data?.total}
|
||||
LoaderView={<DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />}
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<OrgTeamsFilter />
|
||||
|
||||
@@ -17,10 +17,7 @@ type Props = {
|
||||
sorting: SortingState;
|
||||
};
|
||||
|
||||
type Batch = {
|
||||
data: RoutingData[];
|
||||
nextCursor: number | undefined;
|
||||
};
|
||||
const BATCH_SIZE = 100; // Increased batch size for downloads
|
||||
|
||||
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
const { t } = useLocale();
|
||||
@@ -31,12 +28,12 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const fetchBatch = async (
|
||||
cursor: number | undefined = undefined
|
||||
offset: number
|
||||
): Promise<{
|
||||
data: RoutingData[];
|
||||
nextCursor: number | undefined;
|
||||
total: number;
|
||||
}> => {
|
||||
const { data, nextCursor } = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -46,27 +43,28 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
cursor,
|
||||
limit: BATCH_SIZE,
|
||||
offset,
|
||||
});
|
||||
return {
|
||||
data,
|
||||
nextCursor,
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleDownloadClick = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
let allData: RoutingData[] = [];
|
||||
let hasMore = true;
|
||||
let cursor: number | undefined = undefined;
|
||||
let offset = 0;
|
||||
|
||||
// Fetch data in batches until there's no more data
|
||||
while (hasMore) {
|
||||
const result: Batch = await fetchBatch(cursor);
|
||||
// Get first batch to get total count
|
||||
const firstBatch = await fetchBatch(0);
|
||||
allData = [...firstBatch.data];
|
||||
const totalRecords = firstBatch.total;
|
||||
|
||||
// Continue fetching remaining batches
|
||||
while (allData.length < totalRecords) {
|
||||
offset += BATCH_SIZE;
|
||||
const result = await fetchBatch(offset);
|
||||
allData = [...allData, ...result.data];
|
||||
hasMore = result.nextCursor !== undefined;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
|
||||
if (allData.length > 0) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const rawDataInputSchema = z.object({
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
}) satisfies z.ZodType<RawDataInput>;
|
||||
|
||||
export type RoutingFormResponsesInput = {
|
||||
export type RoutingFormStatsInput = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
teamId?: number;
|
||||
@@ -31,13 +31,16 @@ export type RoutingFormResponsesInput = {
|
||||
memberUserIds?: number[];
|
||||
isAll: boolean;
|
||||
routingFormId?: string;
|
||||
cursor?: number;
|
||||
limit?: number;
|
||||
columnFilters: ColumnFilter[];
|
||||
sorting?: Sorting[];
|
||||
};
|
||||
|
||||
export const routingFormResponsesInputSchema = z.object({
|
||||
export type RoutingFormResponsesInput = RoutingFormStatsInput & {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export const routingFormStatsInputSchema = z.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
teamId: z.coerce.number().optional(),
|
||||
@@ -45,8 +48,12 @@ export const routingFormResponsesInputSchema = z.object({
|
||||
memberUserIds: z.number().array().optional(),
|
||||
isAll: z.coerce.boolean(),
|
||||
routingFormId: z.string().optional(),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
columnFilters: z.array(ZColumnFilter),
|
||||
sorting: z.array(ZSorting).optional(),
|
||||
}) satisfies z.ZodType<RoutingFormStatsInput>;
|
||||
|
||||
export const routingFormResponsesInputSchema = z.object({
|
||||
...routingFormStatsInputSchema.shape,
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().max(100).optional(),
|
||||
}) satisfies z.ZodType<RoutingFormResponsesInput>;
|
||||
|
||||
@@ -12,10 +12,12 @@ import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
|
||||
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import type { RoutingFormResponsesInput } from "@calcom/features/insights/server/raw-data.schema";
|
||||
import type { TypedColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import type {
|
||||
RoutingFormResponsesInput,
|
||||
RoutingFormStatsInput,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { type ResponseValue, ZResponse } from "../lib/types";
|
||||
|
||||
@@ -27,17 +29,8 @@ type RoutingFormInsightsTeamFilter = {
|
||||
routingFormId?: string | null;
|
||||
};
|
||||
|
||||
type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
memberUserId?: number | null;
|
||||
searchQuery?: string | null;
|
||||
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
|
||||
fieldFilter?: {
|
||||
fieldId: string;
|
||||
optionId: string;
|
||||
} | null;
|
||||
columnFilters: ColumnFilter[];
|
||||
type RoutingFormStatsFilter = RoutingFormStatsInput & {
|
||||
organizationId: number | null;
|
||||
};
|
||||
|
||||
type RoutingFormResponsesFilter = RoutingFormResponsesInput & {
|
||||
@@ -114,13 +107,11 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: RoutingFormResponsesFilter) {
|
||||
}: RoutingFormStatsFilter) {
|
||||
const whereClause = await this.getWhereClauseForRoutingFormResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
@@ -128,8 +119,6 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
@@ -288,8 +277,8 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
@@ -302,8 +291,6 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
@@ -336,16 +323,14 @@ class RoutingEventsInsights {
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
|
||||
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
|
||||
|
||||
const hasNextPage = responses.length > (limit ?? 0);
|
||||
const responsesToReturn = responses.slice(0, limit ? limit : responses.length);
|
||||
type Response = Omit<
|
||||
(typeof responsesToReturn)[number],
|
||||
(typeof responses)[number],
|
||||
"response" | "responseLowercase" | "bookingAttendees"
|
||||
> & {
|
||||
response: Record<string, ResponseValue>;
|
||||
@@ -355,8 +340,7 @@ class RoutingEventsInsights {
|
||||
|
||||
return {
|
||||
total: totalResponses,
|
||||
data: responsesToReturn as Response[],
|
||||
nextCursor: hasNextPage ? responsesToReturn[responsesToReturn.length - 1].id : undefined,
|
||||
data: responses as Response[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,8 +351,8 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
@@ -388,10 +372,10 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
userId,
|
||||
memberUserIds,
|
||||
limit,
|
||||
offset,
|
||||
columnFilters,
|
||||
sorting,
|
||||
});
|
||||
@@ -444,7 +428,7 @@ class RoutingEventsInsights {
|
||||
|
||||
return {
|
||||
data: dataWithFlatResponse,
|
||||
nextCursor: data.nextCursor,
|
||||
total: data.total,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
rawDataInputSchema,
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
@@ -1574,7 +1575,7 @@ export const insightsRouter = router({
|
||||
});
|
||||
}),
|
||||
routingFormsByStatus: userBelongsToTeamProcedure
|
||||
.input(routingFormResponsesInputSchema)
|
||||
.input(routingFormStatsInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormStats({
|
||||
teamId: input.teamId,
|
||||
@@ -1583,10 +1584,8 @@ export const insightsRouter = router({
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
@@ -1601,10 +1600,10 @@ export const insightsRouter = router({
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
@@ -1619,10 +1618,10 @@ export const insightsRouter = router({
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
cursor: input.cursor,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit ?? BATCH_SIZE,
|
||||
offset: input.offset,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { createEvent } from "ics";
|
||||
import { RRule } from "rrule";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
|
||||
import { getCalendarLinks, CalendarLinkType } from "./getCalendarLinks";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("ics", () => ({
|
||||
createEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/isRecurringEvent", () => ({
|
||||
parseRecurringEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getCalendarLinks", () => {
|
||||
// Mock data for tests
|
||||
const mockStartTime = new Date("2023-01-15T10:00:00Z");
|
||||
const mockEndTime = new Date("2023-01-15T11:00:00Z");
|
||||
const mockDayjsStartTime = dayjs(mockStartTime);
|
||||
const mockDayjsEndTime = dayjs(mockEndTime);
|
||||
|
||||
// Mock translation function
|
||||
const mockT = ((key: string) => key) as TFunction;
|
||||
|
||||
// Mock createEvent implementation
|
||||
const mockIcsValue = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR";
|
||||
|
||||
// Baseline mock objects that can be reused across tests
|
||||
const baseMockBooking = {
|
||||
startTime: mockStartTime,
|
||||
endTime: mockEndTime,
|
||||
location: "Test Location",
|
||||
title: "Test Title",
|
||||
responses: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const baseMockEventType = {
|
||||
recurringEvent: {},
|
||||
description: "Test Description",
|
||||
eventName: "Test Event",
|
||||
isDynamic: false,
|
||||
length: 60,
|
||||
team: null,
|
||||
users: [{ name: "Test User" }],
|
||||
title: "Test Title",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
(createEvent as jest.Mock).mockReturnValue({ value: mockIcsValue });
|
||||
(parseRecurringEvent as jest.Mock).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return all calendar links", async () => {
|
||||
const result = getCalendarLinks({ booking: baseMockBooking, eventType: baseMockEventType, t: mockT });
|
||||
const googleLink = result.find((link) => link.id === CalendarLinkType.GOOGLE_CALENDAR);
|
||||
const microsoftOfficeLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OFFICE);
|
||||
const microsoftOutlookLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OUTLOOK);
|
||||
const icsLink = result.find((link) => link.id === CalendarLinkType.ICS);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(googleLink).toBeDefined();
|
||||
expect(microsoftOfficeLink).toBeDefined();
|
||||
expect(microsoftOutlookLink).toBeDefined();
|
||||
expect(icsLink).toBeDefined();
|
||||
|
||||
(function verifyGoogleLink() {
|
||||
expect(googleLink?.link).toContain("https://calendar.google.com/calendar/r/eventedit");
|
||||
expect(googleLink?.link).toContain("dates=");
|
||||
expect(googleLink?.link).toContain(
|
||||
`${mockDayjsStartTime.utc().format("YYYYMMDDTHHmmss[Z]")}/${mockDayjsEndTime
|
||||
.utc()
|
||||
.format("YYYYMMDDTHHmmss[Z]")}`
|
||||
);
|
||||
expect(googleLink?.link).toContain(`details=${encodeURIComponent(baseMockEventType.description)}`);
|
||||
})();
|
||||
|
||||
(function verifyMicrosoftOfficeLink() {
|
||||
expect(microsoftOfficeLink?.link).toContain("https://outlook.office.com/calendar/0/deeplink/compose");
|
||||
expect(microsoftOfficeLink?.link).toContain("enddt=");
|
||||
expect(microsoftOfficeLink?.link).toContain("startdt=");
|
||||
expect(microsoftOfficeLink?.link).toContain("body=Test%20Description");
|
||||
})();
|
||||
|
||||
(function verifymicrosoftOutlookLink() {
|
||||
expect(microsoftOutlookLink?.link).toContain("https://outlook.live.com/calendar/0/deeplink/compose");
|
||||
expect(microsoftOutlookLink?.link).toContain("enddt=");
|
||||
expect(microsoftOutlookLink?.link).toContain("startdt=");
|
||||
expect(microsoftOutlookLink?.link).toContain("body=Test%20Description");
|
||||
})();
|
||||
|
||||
(function verifyIcsLink() {
|
||||
expect(icsLink).toBeDefined();
|
||||
expect(icsLink?.link).toContain("data:text/calendar");
|
||||
expect(icsLink?.link).toContain(encodeURIComponent(mockIcsValue));
|
||||
expect(createEvent).toHaveBeenCalled();
|
||||
expect(createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: baseMockEventType.description,
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should use videoCallUrl from metadata when available", async () => {
|
||||
const videoCallUrl = "https://zoom.com/test";
|
||||
const booking = {
|
||||
...baseMockBooking,
|
||||
metadata: { videoCallUrl },
|
||||
};
|
||||
|
||||
const result = getCalendarLinks({ booking, eventType: baseMockEventType, t: mockT });
|
||||
|
||||
// Check that all links contain the videoCallUrl
|
||||
const googleLink = result.find((link) => link.id === CalendarLinkType.GOOGLE_CALENDAR);
|
||||
const microsoftOfficeLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OFFICE);
|
||||
const microsoftOutlookLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OUTLOOK);
|
||||
|
||||
expect(googleLink?.link).toContain(`location=${encodeURIComponent(videoCallUrl)}`);
|
||||
expect(microsoftOfficeLink?.link).toContain(`location=${encodeURIComponent(videoCallUrl)}`);
|
||||
expect(microsoftOutlookLink?.link).toContain(`location=${encodeURIComponent(videoCallUrl)}`);
|
||||
});
|
||||
|
||||
it("should handle custom title from dynamic event", async () => {
|
||||
const customTitle = "Custom Dynamic Title";
|
||||
const booking = {
|
||||
...baseMockBooking,
|
||||
responses: { title: customTitle, name: "Test Attendee" },
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...baseMockEventType,
|
||||
isDynamic: true, // This makes it use the custom title
|
||||
};
|
||||
|
||||
const result = getCalendarLinks({ booking, eventType, t: mockT });
|
||||
|
||||
const googleLink = result.find((link) => link.id === CalendarLinkType.GOOGLE_CALENDAR);
|
||||
expect(googleLink?.link).toContain(`details=${encodeURIComponent(eventType.description)}`);
|
||||
expect(googleLink?.link).toContain(`text=${customTitle}`);
|
||||
|
||||
// Check Office 365 link
|
||||
const microsoftOfficeLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OFFICE);
|
||||
expect(microsoftOfficeLink?.link).toContain("body=Test%20Description");
|
||||
expect(microsoftOfficeLink?.link).toContain(`subject=${customTitle}`);
|
||||
|
||||
// Check Outlook link
|
||||
const microsoftOutlookLink = result.find((link) => link.id === CalendarLinkType.MICROSOFT_OUTLOOK);
|
||||
expect(microsoftOutlookLink?.link).toContain("body=Test%20Description");
|
||||
expect(microsoftOutlookLink?.link).toContain(`subject=${encodeURIComponent(customTitle)}`);
|
||||
});
|
||||
|
||||
it("should handle recurring events - Only Google Calendar supports at the moment", async () => {
|
||||
// Mock a recurring event rule
|
||||
const mockRecurringRule = {
|
||||
freq: 2, // WEEKLY
|
||||
interval: 1,
|
||||
count: 5,
|
||||
};
|
||||
|
||||
(parseRecurringEvent as jest.Mock).mockReturnValue(mockRecurringRule);
|
||||
|
||||
const eventType = {
|
||||
...baseMockEventType,
|
||||
recurringEvent: { count: 5, freq: 2, interval: 1 },
|
||||
};
|
||||
|
||||
const mockRRuleString = "FREQ=WEEKLY;INTERVAL=1;COUNT=5";
|
||||
vi.spyOn(RRule.prototype, "toString").mockReturnValue(mockRRuleString);
|
||||
|
||||
const result = getCalendarLinks({ booking: baseMockBooking, eventType, t: mockT });
|
||||
const googleLink = result.find((link) => link.id === "googleCalendar");
|
||||
|
||||
expect(googleLink).toBeDefined();
|
||||
expect(googleLink?.link).toContain(`recur=${encodeURIComponent(mockRRuleString)}`);
|
||||
});
|
||||
|
||||
it("should handle error in ICS generation", async () => {
|
||||
// Mock createEvent to return an error
|
||||
(createEvent as jest.Mock).mockReturnValue({ error: new Error("ICS generation failed") });
|
||||
|
||||
// Mock console.error to avoid test output noise
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const result = getCalendarLinks({ booking: baseMockBooking, eventType: baseMockEventType, t: mockT });
|
||||
const icsLink = result.find((link) => link.id === "ics");
|
||||
|
||||
expect(icsLink).toBeDefined();
|
||||
expect(icsLink?.link).toBe("");
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify createEvent was called with the correct description
|
||||
expect(createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: baseMockEventType.description,
|
||||
})
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createEvent } from "ics";
|
||||
import { RRule } from "rrule";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { nameObjectSchema } from "@calcom/lib/event";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { getEventName } from "../event";
|
||||
|
||||
type RecurringEventOrPrismaJsonObject = RecurringEvent | Prisma.JsonObject | null | undefined;
|
||||
export const enum CalendarLinkType {
|
||||
GOOGLE_CALENDAR = "googleCalendar",
|
||||
MICROSOFT_OFFICE = "microsoftOffice",
|
||||
MICROSOFT_OUTLOOK = "microsoftOutlook",
|
||||
ICS = "ics",
|
||||
}
|
||||
|
||||
const buildICalLink = ({
|
||||
startTime,
|
||||
endTime,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
}: {
|
||||
startTime: Dayjs;
|
||||
endTime: Dayjs;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
}) => {
|
||||
const durationInMinutes = endTime.diff(startTime, "minutes");
|
||||
|
||||
const iCalEvent = createEvent({
|
||||
start: [
|
||||
startTime.toDate().getUTCFullYear(),
|
||||
(startTime.toDate().getUTCMonth() as number) + 1,
|
||||
startTime.toDate().getUTCDate(),
|
||||
startTime.toDate().getUTCHours(),
|
||||
startTime.toDate().getUTCMinutes(),
|
||||
],
|
||||
startInputType: "utc",
|
||||
title,
|
||||
duration: {
|
||||
minutes: durationInMinutes,
|
||||
},
|
||||
...(description ? { description } : {}),
|
||||
...(location ? { location } : {}),
|
||||
});
|
||||
|
||||
if (iCalEvent.error) {
|
||||
throw iCalEvent.error;
|
||||
}
|
||||
|
||||
return `data:text/calendar,${encodeURIComponent(iCalEvent.value ? iCalEvent.value : false)}`;
|
||||
};
|
||||
|
||||
const buildGoogleCalendarLink = ({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation,
|
||||
recurringEvent,
|
||||
}: {
|
||||
startTime: Dayjs;
|
||||
endTime: Dayjs;
|
||||
eventName: string;
|
||||
eventDescription: string | null;
|
||||
bookingLocation: string | null;
|
||||
recurringEvent: RecurringEventOrPrismaJsonObject;
|
||||
}) => {
|
||||
const startTimeInUtcFormat = startTime.utc().format("YYYYMMDDTHHmmss[Z]");
|
||||
const endTimeInUtcFormat = endTime.utc().format("YYYYMMDDTHHmmss[Z]");
|
||||
const recurrence = recurringEvent ? encodeURIComponent(new RRule(recurringEvent).toString()) : "";
|
||||
|
||||
const location = bookingLocation ? encodeURIComponent(bookingLocation) : "";
|
||||
const description = encodeURIComponent(eventDescription ?? "");
|
||||
|
||||
const googleCalendarLink = `https://calendar.google.com/calendar/r/eventedit?dates=${startTimeInUtcFormat}/${endTimeInUtcFormat}&text=${eventName}&details=${description}${
|
||||
location ? `&location=${location}` : ""
|
||||
}${recurrence ? `&recur=${recurrence}` : ""}`;
|
||||
|
||||
return googleCalendarLink;
|
||||
};
|
||||
|
||||
const buildMicrosoftOfficeLink = ({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation,
|
||||
}: {
|
||||
startTime: Dayjs;
|
||||
endTime: Dayjs;
|
||||
eventName: string;
|
||||
eventDescription: string | null;
|
||||
bookingLocation: string | null;
|
||||
}) => {
|
||||
const startTimeInUtcFormat = startTime.utc().format();
|
||||
const endTimeInUtcFormat = endTime.utc().format();
|
||||
const location = bookingLocation ? encodeURIComponent(bookingLocation) : "";
|
||||
const description = encodeURIComponent(eventDescription ?? "");
|
||||
|
||||
// TODO: Why do we need to encode URI this href but not the google calendar link?
|
||||
const microsoftOfficeLink = `https://outlook.office.com/calendar/0/deeplink/compose?body=${description}&enddt=${endTimeInUtcFormat}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${startTimeInUtcFormat}&subject=${eventName}${
|
||||
location ? `&location=${location}` : ""
|
||||
}`;
|
||||
return microsoftOfficeLink;
|
||||
};
|
||||
|
||||
const buildMicrosoftOutlookLink = ({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation,
|
||||
}: {
|
||||
startTime: Dayjs;
|
||||
endTime: Dayjs;
|
||||
eventName: string;
|
||||
eventDescription: string | null;
|
||||
bookingLocation: string | null;
|
||||
}) => {
|
||||
const startTimeInUtcFormat = startTime.utc().format();
|
||||
const endTimeInUtcFormat = endTime.utc().format();
|
||||
const location = bookingLocation ? encodeURIComponent(bookingLocation) : "";
|
||||
const microsoftOutlookLink =
|
||||
encodeURI(
|
||||
`https://outlook.live.com/calendar/0/deeplink/compose?body=${eventDescription}&enddt=${endTimeInUtcFormat}&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=${startTimeInUtcFormat}&subject=${eventName}`
|
||||
) + (location ? `&location=${location}` : "");
|
||||
return microsoftOutlookLink;
|
||||
};
|
||||
|
||||
export const getCalendarLinks = ({
|
||||
booking,
|
||||
eventType,
|
||||
t,
|
||||
}: {
|
||||
booking: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
location: string | null;
|
||||
title: string;
|
||||
responses: Prisma.JsonObject;
|
||||
metadata: Prisma.JsonObject | null;
|
||||
};
|
||||
eventType: {
|
||||
recurringEvent: RecurringEventOrPrismaJsonObject;
|
||||
description?: string | null;
|
||||
eventName?: string | null;
|
||||
isDynamic: boolean;
|
||||
length: number;
|
||||
team?: {
|
||||
name: string;
|
||||
} | null;
|
||||
users: {
|
||||
name: string | null;
|
||||
}[];
|
||||
title: string;
|
||||
};
|
||||
t: TFunction;
|
||||
}) => {
|
||||
let evtName = eventType.eventName;
|
||||
const videoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl ?? null;
|
||||
|
||||
if (eventType.isDynamic && booking.responses.title) {
|
||||
evtName = booking.responses.title as string;
|
||||
}
|
||||
const eventNameObject = {
|
||||
attendeeName: booking.responses.name as z.infer<typeof nameObjectSchema> | string,
|
||||
eventType: eventType.title,
|
||||
eventName: evtName,
|
||||
host: eventType.team?.name || eventType.users[0]?.name || "Nameless",
|
||||
location: booking.location,
|
||||
bookingFields: booking.responses,
|
||||
eventDuration: eventType.length,
|
||||
t,
|
||||
};
|
||||
|
||||
// Create event name and description
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const eventDescription = eventType.description || "";
|
||||
|
||||
// Calculate start and end times
|
||||
const startTime = dayjs(booking.startTime);
|
||||
const endTime = dayjs(booking.endTime);
|
||||
const recurringEvent = eventType.recurringEvent;
|
||||
|
||||
const googleCalendarLink = buildGoogleCalendarLink({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation: videoCallUrl ?? null,
|
||||
recurringEvent,
|
||||
});
|
||||
|
||||
const microsoftOfficeLink = buildMicrosoftOfficeLink({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation: videoCallUrl,
|
||||
});
|
||||
|
||||
const microsoftOutlookLink = buildMicrosoftOutlookLink({
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
eventDescription,
|
||||
bookingLocation: videoCallUrl,
|
||||
});
|
||||
|
||||
// Generate ICS file link
|
||||
let icsFileLink = "";
|
||||
try {
|
||||
icsFileLink = buildICalLink({
|
||||
startTime,
|
||||
endTime,
|
||||
title: eventName,
|
||||
description: eventDescription ?? null,
|
||||
location: videoCallUrl ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating ICS file", error);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Google Calendar",
|
||||
id: CalendarLinkType.GOOGLE_CALENDAR,
|
||||
link: googleCalendarLink,
|
||||
},
|
||||
{
|
||||
label: "Microsoft Office",
|
||||
id: CalendarLinkType.MICROSOFT_OFFICE,
|
||||
link: microsoftOfficeLink,
|
||||
},
|
||||
{
|
||||
label: "Microsoft Outlook",
|
||||
id: CalendarLinkType.MICROSOFT_OUTLOOK,
|
||||
link: microsoftOutlookLink,
|
||||
},
|
||||
{
|
||||
label: "ICS",
|
||||
id: CalendarLinkType.ICS,
|
||||
link: icsFileLink,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -22,9 +22,9 @@ export type EventNameObjectType = {
|
||||
eventName?: string | null;
|
||||
teamName?: string | null;
|
||||
host: string;
|
||||
location?: string;
|
||||
location?: string | null;
|
||||
eventDuration: number;
|
||||
bookingFields?: Prisma.JsonObject;
|
||||
bookingFields?: Prisma.JsonObject | null;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
@@ -106,7 +106,7 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
|
||||
return dynamicEventName;
|
||||
}
|
||||
|
||||
export const validateCustomEventName = (value: string, bookingFields?: Prisma.JsonObject) => {
|
||||
export const validateCustomEventName = (value: string, bookingFields?: Prisma.JsonObject | null) => {
|
||||
let customInputVariables: string[] = [];
|
||||
if (bookingFields) {
|
||||
customInputVariables = Object.keys(bookingFields).map((customInput) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper";
|
||||
import type {
|
||||
BookerPlatformWrapperAtomPropsForIndividual,
|
||||
BookerPlatformWrapperAtomPropsForTeam,
|
||||
} from "../booker/BookerPlatformWrapper";
|
||||
import { BookerPlatformWrapper } from "../booker/BookerPlatformWrapper";
|
||||
} from "../booker/types";
|
||||
import { CalProvider } from "../cal-provider/CalProvider";
|
||||
import { useGetRoutingFormUrlProps } from "./useGetRoutingFormUrlProps";
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo, useEffect, useCallback, useState } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { BookerProps } from "@calcom/features/bookings/Booker";
|
||||
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
|
||||
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
||||
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||
@@ -15,20 +14,12 @@ import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/get
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
|
||||
import type { BookingResponse } from "@calcom/platform-libraries";
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ApiSuccessResponse,
|
||||
ApiSuccessResponseWithoutData,
|
||||
} from "@calcom/platform-types";
|
||||
import type { RoutingFormSearchParams } from "@calcom/platform-types";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import {
|
||||
transformApiEventTypeForAtom,
|
||||
transformApiTeamEventTypeForAtom,
|
||||
} from "../event-types/atom-api-transformers/transformApiEventTypeForAtom";
|
||||
import type { UseCreateBookingInput } from "../hooks/bookings/useCreateBooking";
|
||||
import { useCreateBooking } from "../hooks/bookings/useCreateBooking";
|
||||
import { useCreateInstantBooking } from "../hooks/bookings/useCreateInstantBooking";
|
||||
import { useCreateRecurringBooking } from "../hooks/bookings/useCreateRecurringBooking";
|
||||
@@ -46,59 +37,10 @@ import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
|
||||
import { useMe } from "../hooks/useMe";
|
||||
import { useSlots } from "../hooks/useSlots";
|
||||
import { AtomsWrapper } from "../src/components/atoms-wrapper";
|
||||
|
||||
export type BookerPlatformWrapperAtomProps = Omit<
|
||||
BookerProps,
|
||||
"username" | "entity" | "isTeamEvent" | "teamId"
|
||||
> & {
|
||||
rescheduleUid?: string;
|
||||
rescheduledBy?: string;
|
||||
bookingUid?: string;
|
||||
entity?: BookerProps["entity"];
|
||||
// values for the booking form and booking fields
|
||||
defaultFormValues?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
guests?: string[];
|
||||
name?: string;
|
||||
email?: string;
|
||||
notes?: string;
|
||||
rescheduleReason?: string;
|
||||
} & Record<string, string | string[]>;
|
||||
handleCreateBooking?: (input: UseCreateBookingInput) => void;
|
||||
onCreateBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
|
||||
onCreateBookingError?: (data: ApiErrorResponse | Error) => void;
|
||||
onCreateRecurringBookingSuccess?: (data: ApiSuccessResponse<BookingResponse[]>) => void;
|
||||
onCreateRecurringBookingError?: (data: ApiErrorResponse | Error) => void;
|
||||
onCreateInstantBookingSuccess?: (data: ApiSuccessResponse<BookingResponse>) => void;
|
||||
onCreateInstantBookingError?: (data: ApiErrorResponse | Error) => void;
|
||||
onReserveSlotSuccess?: (data: ApiSuccessResponse<string>) => void;
|
||||
onReserveSlotError?: (data: ApiErrorResponse) => void;
|
||||
onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void;
|
||||
onDeleteSlotError?: (data: ApiErrorResponse) => void;
|
||||
locationUrl?: string;
|
||||
view?: VIEW_TYPE;
|
||||
metadata?: Record<string, string>;
|
||||
bannerUrl?: string;
|
||||
onDryRunSuccess?: () => void;
|
||||
hostsLimit?: number;
|
||||
preventEventTypeRedirect?: boolean;
|
||||
};
|
||||
|
||||
type VIEW_TYPE = keyof typeof BookerLayouts;
|
||||
|
||||
export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & {
|
||||
username: string | string[];
|
||||
isTeamEvent?: false;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
};
|
||||
|
||||
export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomProps & {
|
||||
username?: string | string[];
|
||||
isTeamEvent: true;
|
||||
teamId: number;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
};
|
||||
import type {
|
||||
BookerPlatformWrapperAtomPropsForIndividual,
|
||||
BookerPlatformWrapperAtomPropsForTeam,
|
||||
} from "./types";
|
||||
|
||||
export const BookerPlatformWrapper = (
|
||||
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
|
||||
@@ -504,7 +446,7 @@ export const BookerPlatformWrapper = (
|
||||
}
|
||||
}
|
||||
rescheduleUid={props.rescheduleUid ?? null}
|
||||
rescheduledBy={null}
|
||||
rescheduledBy={props.rescheduledBy}
|
||||
bookingUid={props.bookingUid ?? null}
|
||||
isRedirect={false}
|
||||
fromUserNameRedirected=""
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ApiSuccessResponse,
|
||||
ApiErrorResponse,
|
||||
ApiSuccessResponseWithoutData,
|
||||
RoutingFormSearchParams,
|
||||
} from "@calcom/platform-types";
|
||||
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -48,3 +49,16 @@ export type BookerPlatformWrapperAtomProps = Omit<
|
||||
};
|
||||
|
||||
type VIEW_TYPE = keyof typeof BookerLayouts;
|
||||
|
||||
export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & {
|
||||
username: string | string[];
|
||||
isTeamEvent?: false;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
};
|
||||
|
||||
export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomProps & {
|
||||
username?: string | string[];
|
||||
isTeamEvent: true;
|
||||
teamId: number;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
};
|
||||
|
||||
@@ -31,3 +31,6 @@ export { ConferencingAppsViewPlatformWrapper as ConferencingAppsSettings } from
|
||||
export { StripeConnect } from "./connect/stripe/StripeConnect";
|
||||
export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper";
|
||||
export { PaymentForm } from "./event-types/payments/PaymentForm";
|
||||
|
||||
export { useCreateEventType } from "./hooks/event-types/private/useCreateEventType";
|
||||
export { useCreateTeamEventType } from "./hooks/event-types/private/useCreateTeamEventType";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactElement } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { BookerEmbed } from "../booker-embed";
|
||||
import type { BookerPlatformWrapperAtomPropsForTeam } from "../booker/BookerPlatformWrapper";
|
||||
import type { BookerPlatformWrapperAtomPropsForTeam } from "../booker/types";
|
||||
|
||||
/**
|
||||
* Renders the Router component with predefined props.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -118,7 +118,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!booking.bookingFieldsResponses?.notes && (
|
||||
{"bookingFieldsResponses" in booking && !!booking.bookingFieldsResponses?.notes && (
|
||||
<div className="flex gap-[70px]">
|
||||
<div className="w-[40px]">
|
||||
<h4>Additional notes</h4>
|
||||
@@ -140,7 +140,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/booking?rescheduleUid=${booking?.uid}&eventTypeSlug=${booking?.eventType.slug}`
|
||||
`/booking?rescheduleUid=${booking?.uid}&eventTypeSlug=${booking?.eventType.slug}&rescheduledBy=${props.calEmail}`
|
||||
);
|
||||
}}>
|
||||
Reschedule
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
const { data: teams } = useTeams();
|
||||
const { isLoading: isLoadingTeamEvents, data: teamEventTypes } = useTeamEventTypes(teams?.[0]?.id || 0);
|
||||
const rescheduleUid = (router.query.rescheduleUid as string) ?? "";
|
||||
const rescheduledBy = (router.query.rescheduledBy as string) ?? "";
|
||||
const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? "";
|
||||
|
||||
return (
|
||||
@@ -125,6 +126,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
{!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && (
|
||||
<Booker
|
||||
rescheduleUid={rescheduleUid}
|
||||
rescheduledBy={rescheduledBy}
|
||||
eventSlug={eventTypeSlugQueryParam}
|
||||
username={props.calUsername ?? ""}
|
||||
onCreateBookingSuccess={(data) => {
|
||||
|
||||
@@ -16,19 +16,12 @@ import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { paymentDataSelect } from "@calcom/prisma/selects/payment";
|
||||
import type { TeamQuery } from "@calcom/trpc/server/routers/loggedInViewer/integrations.handler";
|
||||
import { updateHandler as updateScheduleHandler } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.handler";
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
export { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
|
||||
export { slugify } from "@calcom/lib/slugify";
|
||||
export { getBookingForReschedule };
|
||||
export { updateScheduleHandler };
|
||||
export type UpdateScheduleOutputType = Awaited<
|
||||
ReturnType<
|
||||
typeof import("@calcom/trpc/server/routers/viewer/availability/schedule/update.handler").updateHandler
|
||||
>
|
||||
>;
|
||||
|
||||
export { SchedulingType, PeriodType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -49,14 +42,6 @@ export type { ConnectedDestinationCalendars } from "@calcom/lib/getConnectedDest
|
||||
|
||||
export { getBusyCalendarTimes } from "@calcom/lib/CalendarManager";
|
||||
|
||||
export {
|
||||
transformWorkingHoursForAtom,
|
||||
transformAvailabilityForAtom,
|
||||
transformDateOverridesForAtom,
|
||||
transformApiScheduleAvailability,
|
||||
transformApiScheduleOverrides,
|
||||
} from "@calcom/lib/schedules/transformers";
|
||||
|
||||
export type {
|
||||
BookingCreateBody,
|
||||
BookingResponse,
|
||||
@@ -70,7 +55,6 @@ export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezon
|
||||
export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler";
|
||||
|
||||
export { TRPCError } from "@trpc/server";
|
||||
export type { TUpdateInputSchema } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.schema";
|
||||
export { createNewUsersConnectToOrgIfExists };
|
||||
|
||||
export { getAllUserBookings };
|
||||
@@ -113,3 +97,4 @@ export { getTeamMemberEmailForResponseOrContactUsingUrlQuery };
|
||||
export { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
export { encryptServiceAccountKey } from "@calcom/lib/server/serviceAccountKey";
|
||||
export { createHandler as createApiKeyHandler } from "@calcom/trpc/server/routers/viewer/apiKeys/create.handler";
|
||||
export { getCalendarLinks } from "@calcom/lib/bookings/getCalendarLinks";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"scripts": {
|
||||
"build": "yarn vite build",
|
||||
"build:watch": "yarn vite build --watch",
|
||||
"watch-lru-fix": "watch -n 1 'sed -i'' -e \"s/const CACHE = new lruCache\\.LRUCache({ max: 1e3 });/const CACHE = new lruCache({ max: 1e3 });/g\" ./dist/index.cjs'",
|
||||
"build:dev": "yarn vite build && sed -i'' -e 's/const CACHE = new lruCache\\.LRUCache({ max: 1e3 });/const CACHE = new lruCache({ max: 1e3 });/g' ./dist/index.cjs",
|
||||
"local": "node scripts/local.js && rm -rf dist && yarn build:dev && cd ../../.. && yarn",
|
||||
"publish": "yarn && node scripts/prepublish.js && rm -rf dist && yarn build && npm publish --access public",
|
||||
@@ -44,6 +45,11 @@
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./schedules": {
|
||||
"import": "./dist/schedules.js",
|
||||
"require": "./dist/schedules.cjs",
|
||||
"types": "./dist/schedules.d.ts"
|
||||
},
|
||||
"./emails": {
|
||||
"import": "./dist/emails.js",
|
||||
"require": "./dist/emails.cjs",
|
||||
@@ -68,6 +74,9 @@
|
||||
],
|
||||
"app-store": [
|
||||
"dist/app-store.d.ts"
|
||||
],
|
||||
"schedules": [
|
||||
"dist/schedules.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/lib/schedules/transformers/from-api";
|
||||
|
||||
export { transformApiScheduleOverrides, transformApiScheduleAvailability };
|
||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
||||
lib: {
|
||||
entry: {
|
||||
index: resolve(__dirname, "./index.ts"),
|
||||
schedules: resolve(__dirname, "./schedules.ts"),
|
||||
emails: resolve(__dirname, "./emails.ts"),
|
||||
"event-types": resolve(__dirname, "./event-types.ts"),
|
||||
"app-store": resolve(__dirname, "./app-store.ts"),
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsNumber, Min, Max, IsOptional } from "class-validator";
|
||||
import { IsEnum } from "class-validator";
|
||||
@@ -39,7 +39,7 @@ export type ApiResponse<T = undefined> = T extends undefined
|
||||
export type ApiResponseMaybeRedirect<T = undefined> = ApiResponse<T> | ApiRedirectResponseType;
|
||||
|
||||
export class Pagination {
|
||||
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
|
||||
@ApiPropertyOptional({ description: "The number of items to return", example: 10 })
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@@ -47,13 +47,13 @@ export class Pagination {
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "The number of items to skip", example: 0 })
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(250)
|
||||
@IsOptional()
|
||||
offset?: number | null;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class SkipTakePagination {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./reassign-to-user.input";
|
||||
export * from "./reschedule-booking-input.pipe";
|
||||
export * from "./cancel-booking-input.pipe";
|
||||
export * from "./decline-booking.input";
|
||||
export * from "./validators/validate-metadata";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsDateString, IsOptional, IsString } from "class-validator";
|
||||
import { IsDateString, IsOptional, IsString, Validate, isEmail } from "class-validator";
|
||||
|
||||
export const RESCHEDULED_BY_DOCS = `Email of the person who is rescheduling the booking - only needed when rescheduling a booking that requires a confirmation.
|
||||
If event type owner email is provided then rescheduled booking will be automatically confirmed. If attendee email or no email is passed then the event type
|
||||
owner will have to confirm the rescheduled booking.`;
|
||||
|
||||
export class RescheduleBookingInput_2024_08_13 {
|
||||
@IsDateString()
|
||||
@@ -9,6 +13,13 @@ export class RescheduleBookingInput_2024_08_13 {
|
||||
})
|
||||
start!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS })
|
||||
@Validate((value: string) => !value || isEmail(value), {
|
||||
message: "Invalid rescheduledBy email format",
|
||||
})
|
||||
rescheduledBy?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
@@ -26,6 +37,13 @@ export class RescheduleSeatedBookingInput_2024_08_13 {
|
||||
})
|
||||
start!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ description: RESCHEDULED_BY_DOCS })
|
||||
@Validate((value: string) => !value || isEmail(value), {
|
||||
message: "Invalid rescheduledBy email format",
|
||||
})
|
||||
rescheduledBy?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1",
|
||||
|
||||
+11
-13
@@ -1,6 +1,12 @@
|
||||
import type { ValidationOptions, ValidationArguments } from "class-validator";
|
||||
import { registerDecorator } from "class-validator";
|
||||
|
||||
export const METADATA_DOCS = `You can store any additional data you want here.
|
||||
Metadata must have at most 50 keys, each key up to 40 characters.
|
||||
Values can be strings (up to 500 characters), numbers, or booleans.`;
|
||||
|
||||
export type Metadata = Record<string, string | number | boolean>;
|
||||
|
||||
export function ValidateMetadata(validationOptions?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
@@ -41,21 +47,13 @@ export function ValidateMetadata(validationOptions?: ValidationOptions) {
|
||||
}
|
||||
|
||||
function isInvalidValidKeyValue(keyValue: any) {
|
||||
if (!isString(keyValue)) {
|
||||
return true;
|
||||
if (typeof keyValue === "number" || typeof keyValue === "boolean") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTooLong(keyValue)) {
|
||||
return true;
|
||||
if (typeof keyValue === "string") {
|
||||
return keyValue.length > 500;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isString(keyValue: any): keyValue is string {
|
||||
return typeof keyValue === "string";
|
||||
}
|
||||
|
||||
function isTooLong(keyValue: string) {
|
||||
return keyValue.length > 500;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
import type { Metadata } from "../../bookings/2024-08-13/inputs/validators/validate-metadata";
|
||||
|
||||
export class TeamOutputDto {
|
||||
@IsInt()
|
||||
@@ -79,11 +81,20 @@ export class TeamOutputDto {
|
||||
@ApiPropertyOptional()
|
||||
readonly hideBookATeamMember?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly metadata?: string;
|
||||
@Transform(
|
||||
// note(Lauris): added this transform because without it metadata is removed for some reason
|
||||
({ obj }: { obj: { metadata: Metadata | null | undefined } }) => {
|
||||
return obj.metadata || undefined;
|
||||
}
|
||||
)
|
||||
metadata?: Metadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2295,7 +2295,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.144"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.147"
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
@@ -2330,6 +2330,7 @@ __metadata:
|
||||
bull: ^4.12.4
|
||||
class-transformer: ^0.5.1
|
||||
class-validator: ^0.14.0
|
||||
concurrently: ^9.1.2
|
||||
cookie-parser: ^1.4.6
|
||||
dotenv: ^16.3.1
|
||||
fs-extra: ^11.2.0
|
||||
@@ -2931,7 +2932,7 @@ __metadata:
|
||||
"@calcom/trpc": "*"
|
||||
"@calcom/ui": "*"
|
||||
bcryptjs: ^2.4.3
|
||||
dub: ^0.55.1
|
||||
dub: ^0.59.1
|
||||
handlebars: ^4.7.7
|
||||
jose: ^4.13.1
|
||||
lru-cache: ^9.0.3
|
||||
@@ -3341,13 +3342,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.144":
|
||||
version: 0.0.144
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.144"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.147":
|
||||
version: 0.0.147
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.147"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 2b4ac2ca7bf343baf34106fdc68cece3fac9e9281112d05fb3a1ba1cd3c6357b4d1db9f844588908535fccc093ece36f295f4889ad868b3848030b9f3000b0d8
|
||||
checksum: 4fdbc943af88af5fc00121760b5ea7fb8657e1468ecf06b46b97444889f18cadfb4f5eba10a3d55eb705a290a87f427b4e92f14243788e6adc13b50085d47779
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18671,6 +18672,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"concurrently@npm:^9.1.2":
|
||||
version: 9.1.2
|
||||
resolution: "concurrently@npm:9.1.2"
|
||||
dependencies:
|
||||
chalk: ^4.1.2
|
||||
lodash: ^4.17.21
|
||||
rxjs: ^7.8.1
|
||||
shell-quote: ^1.8.1
|
||||
supports-color: ^8.1.1
|
||||
tree-kill: ^1.2.2
|
||||
yargs: ^17.7.2
|
||||
bin:
|
||||
conc: dist/bin/concurrently.js
|
||||
concurrently: dist/bin/concurrently.js
|
||||
checksum: 9e25e8ee6272ada26739aff1fb43e96ac458fafca82f45b8360bdd9115d60bbc679d282dfc52001b861b6e9f32b3063aed975691d8dec9e62807a9679763a1d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"conf@npm:10.2.0":
|
||||
version: 10.2.0
|
||||
resolution: "conf@npm:10.2.0"
|
||||
@@ -20289,12 +20308,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dub@npm:^0.55.1":
|
||||
version: 0.55.1
|
||||
resolution: "dub@npm:0.55.1"
|
||||
"dub@npm:^0.59.1":
|
||||
version: 0.59.1
|
||||
resolution: "dub@npm:0.59.1"
|
||||
peerDependencies:
|
||||
"@modelcontextprotocol/sdk": ^1.5.0
|
||||
zod: ">= 3"
|
||||
checksum: 57ee31901267107de0261c1a4963df7978ff915fe2544ef0a5c3e051f6731a42162d3ff521e88cce21f790edd155dc5388d3bb44e2f0d0d0bda9ece2b6cdadb5
|
||||
peerDependenciesMeta:
|
||||
"@modelcontextprotocol/sdk":
|
||||
optional: true
|
||||
bin:
|
||||
mcp: bin/mcp-server.js
|
||||
checksum: e7186f2c7af1fabba7b2dc2f2565e5b6a9883d10bbd31ef1615b5cf2cf62243de3d55a992baa02dc5327a113c2fd363c3d2291871585014abd13788b9d20afd1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -34930,6 +34955,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shell-quote@npm:^1.8.1":
|
||||
version: 1.8.2
|
||||
resolution: "shell-quote@npm:1.8.2"
|
||||
checksum: 1e97b62ced1c4c5135015978ebf273bed1f425a68cf84163e83fbb0f34b3ff9471e656720dab2b7cbb4ae0f58998e686d17d166c28dfb3662acd009e8bd7faed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shelljs@npm:0.8.5":
|
||||
version: 0.8.5
|
||||
resolution: "shelljs@npm:0.8.5"
|
||||
@@ -36993,7 +37025,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tree-kill@npm:1.2.2":
|
||||
"tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "tree-kill@npm:1.2.2"
|
||||
bin:
|
||||
|
||||
Reference in New Issue
Block a user