Compare commits

..
16 Commits
Author SHA1 Message Date
GitHub Actions 1e9f3a8060 chore: release v5.0.17
Post release / release (push) Canceled after 0s
2025-03-11 22:22:13 +00:00
Morgan bc1d73e9fe fix: broken metadata import from platform/types (#19986)
* fix: broken metadata import from platform/types

* fix: OAuthClientUsersService instead of this in oauth-clients-users.service.ts
2025-03-11 22:05:40 +00:00
GitHub Actions eca01be58c chore: release v5.0.16 2025-03-11 21:51:10 +00:00
4ad1127184 refactor: extract schedules from libraries barrel file (#19838)
* refactor: extract schedules from libraries barrel file

* cleanup: remove functions not being used by schedules

* fix tsconfig ]

* bump platform libs

* bump platform libs

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-03-11 19:07:06 +00:00
Lauris SkraucisandMorgan Vernay 76b9b9b0fe fix: v2 rescheduled booking with confirmation (#19833)
* fix: old bookings controller

* fix: new bookings controller

* fix: BookerPlatformWrapper pass rescheduledBy to BookerComponent

* rescheduleBy email

* fix test

* remove log

* centralize getOAuthUserEmail, examples app rescheduledBy

* revert

* finalize

* fix: old bookings rescheduling

* unify email validation

* update input description

* chore: move booker platform wrapper types

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-03-11 20:04:59 +01:00
Benny Joo ff5c6de5da chore: remove code not needed in defaultResponder (#19976)
* 1 line change

* fix test
2025-03-11 17:47:01 +00:00
Eunjae Lee eafb61f515 fix: apply standard pagination to /insights/routing (#19950)
* fix: apply standard pagination to /insights/routing

* fix type error

* max limit
2025-03-11 16:51:11 +00:00
Benny Joo d0ca0dab53 fix: do not export client components directly from RSC (#19971) 2025-03-11 16:11:30 +00:00
Lauris SkraucisandMorgan e6f561c3d7 fix: v2 team metadata (#19939)
* refactor: allow metadata to also receive numbers and booleans

* refactor: Team metadata

* refactor: Managed orgs metadata

* refactor: Team metadata

* test: organizations teams create team with metadata

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-03-11 15:03:30 +00:00
Lauris Skraucis b7c5cfcf02 feat: v2 get managed users by email (#19526)
* feat: v2 get managed users by email

* docs

* fix: offset query param not showing up in docs

* finish merge main

* fix: check if each email in emails query param is email
2025-03-11 15:01:43 +00:00
Hariom BalharaandMorgan Vernay b1bcbe9dfb feat: calendar-links API (#19777)
* refactor: Remove intervalLimits from @calcom/lib and export directly (#19710)
* refactor: Remove intervalLimits from @calcom/lib and export directly

* Tackle other places that use parseBookingLimit/parseDurationLimit

* More type fixups that were hidden by previous fails

* Fixed up booking-limits file

* Remove server-only

## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings

* Add unit tests and e2e

* fix ts errors

* Add comment

* bump platform libs

* fix: yarn-lock

* fix yarn.lock

* doc update

* Add isString

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-03-11 17:01:32 +02:00
Steven Tey 19e1714794 Upgrade dub to latest version for dual-sided incentives (#19885) 2025-03-11 14:03:54 +00:00
Benny Joo e3e624464d fix: update wrong oauth tests and parseRequestData util (#19958)
* update oauth tests

* fix parseRequestData

* add logging

* add fallback

* use ??

* updates

* use parseUrlFormData in saml/callback

* update tests

* fix

* remove log

* update

* any -> unknown

* use HttpErrors

* addressed

* unknown -> any
2025-03-11 14:01:48 +00:00
amrit dc3c7b43bf fix: correct translation key for host and attendee emails (#19962) 2025-03-11 13:35:59 +00:00
Morgan 83736785b7 fix: catch error for getOwnerId with invalid token (#19949) 2025-03-11 13:27:20 +02:00
Rajiv Sahal 83b6b7caf9 chore: update hooks sections for platform docs (#19726)
* export custom hooks to create event types

* update hooks page

* separate hooks into their own files
2025-03-11 16:57:05 +05:30
88 changed files with 5078 additions and 1551 deletions
+8
View File
@@ -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"
View File
+9
View File
@@ -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
View File
@@ -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
```
+10 -3
View File
@@ -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;
@@ -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();
});
});
});
@@ -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();
});
});
});
});
@@ -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 },
@@ -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";
@@ -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[];
}
@@ -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 () => {
@@ -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}`;
}
@@ -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;
}
@@ -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;
}
@@ -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 };
});
});
@@ -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 {
@@ -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" });
}
}
@@ -42,7 +42,7 @@ export class ManagedOrganizationsService {
...organizationData,
isOrganization: true,
isPlatform: true,
metadata: JSON.stringify(organizationData.metadata || {}),
metadata: organizationData.metadata,
}
);
@@ -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);
+102
View File
@@ -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": {
+2
View File
@@ -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 = {
+3 -2
View File
@@ -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;
}
+2 -1
View File
@@ -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 });
+2 -3
View File
@@ -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({});
+46 -14
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.0.15",
"version": "5.0.17",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+30 -30
View File
@@ -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",
},
});
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -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",
+101
View File
@@ -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>
</>
);
}
```
+137
View File
@@ -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>
</>
);
}
```
+170
View File
@@ -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>
</>
);
}
```
-306
View File
@@ -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>
);
})}
</>
);
}
```
+1 -1
View File
@@ -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();
});
});
+255
View File
@@ -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,
},
];
};
+3 -3
View File
@@ -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=""
+14
View File
@@ -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;
};
+3
View File
@@ -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";
+1 -1
View File
@@ -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.
@@ -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) => {
+1 -16
View File
@@ -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";
+9
View File
@@ -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"
]
}
}
+6
View File
@@ -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"),
+4 -4
View File
@@ -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",
@@ -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.
+43 -11
View File
@@ -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: