Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
RequestRescheduleInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingsInput_2024_08_13,
GetBookingsOutput_2024_08_13,
Expand Down Expand Up @@ -51,6 +52,7 @@ import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-1
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { RequestRescheduleOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/request-reschedule.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
Expand Down Expand Up @@ -532,6 +534,30 @@ export class BookingsController_2024_08_13 {
};
}

@Post("/:bookingUid/request-reschedule")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Request to reschedule a booking",
description: `Request to reschedule a booking. The booking will be cancelled and the attendee will receive an email with a link to reschedule.

<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async requestReschedule(
@Param("bookingUid") bookingUid: string,
@Body() body: RequestRescheduleInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<RequestRescheduleOutput_2024_08_13> {
await this.bookingsService.requestReschedule(bookingUid, user, body.rescheduleReason);

return {
status: SUCCESS_STATUS,
};
}

@Get("/:bookingUid/calendar-links")
@UseGuards(ApiAuthGuard, BookingUidGuard)
@Permissions([BOOKING_READ])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
AttendeeWasRequestedToRescheduleEmail,
OrganizerRequestedToRescheduleEmail,
OrganizerScheduledEmail,
} from "@calcom/platform-libraries/emails";
import type { Booking, PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import 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 { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { RequestRescheduleOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/request-reschedule.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";

jest
.spyOn(OrganizerRequestedToRescheduleEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeWasRequestedToRescheduleEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));

describe("Bookings Endpoints 2024-08-13", () => {
describe("Request reschedule", () => {
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 = `request-reschedule-2024-08-13-user-${randomString()}@api.com`;
let user: User;

let eventTypeId: number;

let createdBooking: Booking;
let createdBooking2: Booking;
let cancelledBooking: 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: `request-reschedule-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: `request-reschedule-2024-08-13-${randomString()}-schedule`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{
title: `request-reschedule-2024-08-13-${randomString()}-event-type`,
slug: `request-reschedule-2024-08-13-${randomString()}-event-type-slug`,
length: 60,
},
user.id
);
eventTypeId = event.id;

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

await app.init();
});

async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";

const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}

it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});

it("should request reschedule of a booking with a reason", async () => {
createdBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "request reschedule test booking",
uid: `request-reschedule-test-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "en",
timeZone: "Europe/Rome",
},
},
status: "ACCEPTED",
});

return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/request-reschedule`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.send({ rescheduleReason: "I have a conflict and need to move this meeting" })
.expect(200)
.then(async (response) => {
const responseBody: RequestRescheduleOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);

const dbBooking = await bookingsRepositoryFixture.getByUid(createdBooking.uid);
expect(dbBooking?.status).toEqual("CANCELLED");
expect(dbBooking?.rescheduled).toEqual(true);
expect(dbBooking?.cancellationReason).toEqual(
"I have a conflict and need to move this meeting"
);
});
});

it("should request reschedule of a booking without a reason", async () => {
createdBooking2 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 11, 0, 0)),
title: "request reschedule no reason test",
uid: `request-reschedule-no-reason-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Alice",
email: "alice@gmail.com",
},
attendees: {
create: {
email: "alice@gmail.com",
name: "Alice",
locale: "en",
timeZone: "Europe/Rome",
},
},
status: "ACCEPTED",
});

return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking2.uid}/request-reschedule`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.send({})
.expect(200)
.then(async (response) => {
const responseBody: RequestRescheduleOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);

const dbBooking = await bookingsRepositoryFixture.getByUid(createdBooking2.uid);
expect(dbBooking?.status).toEqual("CANCELLED");
expect(dbBooking?.rescheduled).toEqual(true);
});
});

it("should not request reschedule of a cancelled booking", async () => {
cancelledBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 9, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 9, 11, 0, 0)),
title: "cancelled booking test",
uid: `cancelled-booking-test-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Charlie",
email: "charlie@gmail.com",
},
attendees: {
create: {
email: "charlie@gmail.com",
name: "Charlie",
locale: "en",
timeZone: "Europe/Rome",
},
},
status: "CANCELLED",
});

return request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/request-reschedule`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.send({ rescheduleReason: "Trying to reschedule a cancelled booking" })
.expect(400);
});

it("should return 404 for non-existent booking uid", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/non-existent-uid-${randomString()}/request-reschedule`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.send({ rescheduleReason: "test" })
.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();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";

import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";

export class RequestRescheduleOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getTranslation,
handleCancelBooking,
handleMarkNoShow,
requestRescheduleHandler,
roundRobinManualReassignment,
roundRobinReassignment,
} from "@calcom/platform-libraries";
Expand Down Expand Up @@ -1283,6 +1284,24 @@ export class BookingsService_2024_08_13 {
return this.getBooking(bookingUid, requestUser);
}

async requestReschedule(bookingUid: string, requestUser: ApiAuthGuardUser, rescheduleReason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}

await requestRescheduleHandler({
ctx: {
user: requestUser,
},
input: {
bookingUid,
rescheduleReason,
},
source: "API_V2",
});
}

async getCalendarLinks(bookingUid: string): Promise<CalendarLink[]> {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);

Expand Down
Loading
Loading