feat: v2 subsequent recurring booking cancellation (#17645)
* refactor: cancelling all remaining seated recurring bookings not possible * chore: document cancel booking endpoint * chore: remove unused imports * feat: cancel subsequent bookigns * fix: flaky test * refactor: getCanceledSubsequentBookings * refactor: after cancellation return whole recurrence sequence
This commit is contained in:
1 parent
ce0d685849
commit
2b1041460c
16 files changed
+814
-343
No files matched your search
@@ -29,7 +29,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -191,7 +191,12 @@ export class BookingsController_2024_08_13 {
|
||||
@Post("/:bookingUid/cancel")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Cancel a booking" })
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||
})
|
||||
async cancelBooking(
|
||||
@Req() request: Request,
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
|
||||
@@ -25,13 +25,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Creating recurring bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const maxRecurrenceCount = 3;
|
||||
let recurringEventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: "peer coding recurring",
|
||||
slug: "peer-coding-recurring",
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.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 not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
|
||||
const recurrenceCount = 1000;
|
||||
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
|
||||
const recurrenceCount = maxRecurrenceCount - 1;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount - 1);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
|
||||
const recurrenceCount = maxRecurrenceCount;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.meetingUrl).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
|
||||
const thirdBooking = data[2];
|
||||
expect(thirdBooking.id).toBeDefined();
|
||||
expect(thirdBooking.uid).toBeDefined();
|
||||
expect(thirdBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(thirdBooking.status).toEqual("accepted");
|
||||
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
|
||||
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
|
||||
expect(thirdBooking.duration).toEqual(60);
|
||||
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(thirdBooking.recurringBookingUid).toBeDefined();
|
||||
expect(thirdBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(thirdBooking.location).toEqual(body.location);
|
||||
expect(thirdBooking.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
describe("Recurring bookings cancel all subsequent bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const maxRecurrenceCount = 4;
|
||||
let recurringEventTypeId: number;
|
||||
|
||||
let recurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: "peer coding recurring",
|
||||
slug: "peer-coding-recurring",
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.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 create a recurring booking", async () => {
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount);
|
||||
recurringBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel all recurrences after a specific recurrence", async () => {
|
||||
const recurringBookingUid = recurringBooking[2].recurringBookingUid;
|
||||
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
|
||||
for (const booking of bookings) {
|
||||
expect(booking.status).toEqual("ACCEPTED");
|
||||
}
|
||||
|
||||
// note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences
|
||||
// aka cancel 3rd and 4th recurrences.
|
||||
const thirdRecurrenceUid = recurringBooking[2].uid;
|
||||
const fourthRecurringUid = recurringBooking[3].uid;
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancelSubsequentBookings: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${thirdRecurrenceUid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
|
||||
const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid);
|
||||
expect(firstRecurrence).toBeDefined();
|
||||
expect(firstRecurrence?.status).toEqual("accepted");
|
||||
|
||||
const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid);
|
||||
expect(secondRecurrence).toBeDefined();
|
||||
expect(secondRecurrence?.status).toEqual("accepted");
|
||||
|
||||
const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid);
|
||||
expect(thirdRecurrence).toBeDefined();
|
||||
expect(thirdRecurrence?.status).toEqual("cancelled");
|
||||
|
||||
const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid);
|
||||
expect(fourthRecurrence).toBeDefined();
|
||||
expect(fourthRecurrence?.status).toEqual("cancelled");
|
||||
|
||||
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
|
||||
|
||||
const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid);
|
||||
expect(bookingFirst?.status).toEqual("ACCEPTED");
|
||||
const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid);
|
||||
expect(bookingSecond?.status).toEqual("ACCEPTED");
|
||||
const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid);
|
||||
expect(bookingThird?.status).toEqual("CANCELLED");
|
||||
const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid);
|
||||
expect(bookingFourth?.status).toEqual("CANCELLED");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const maxRecurrenceCount = 4;
|
||||
let recurringEventTypeId: number;
|
||||
|
||||
let recurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: "peer coding recurring",
|
||||
slug: "peer-coding-recurring",
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.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 create a recurring booking", async () => {
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount);
|
||||
recurringBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel all remaning recurrences", async () => {
|
||||
const recurringBookingUid = recurringBooking[0].recurringBookingUid;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${recurringBookingUid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
for (const booking of data) {
|
||||
expect(booking.status).toEqual("cancelled");
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-289
@@ -1127,293 +1127,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
describe("Recurring bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const maxRecurrenceCount = 3;
|
||||
let recurringEventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
{
|
||||
title: "peer coding recurring",
|
||||
slug: "peer-coding-recurring",
|
||||
length: 60,
|
||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.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 not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
|
||||
const recurrenceCount = 1000;
|
||||
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
|
||||
const recurrenceCount = maxRecurrenceCount - 1;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount - 1);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
|
||||
const recurrenceCount = maxRecurrenceCount;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring",
|
||||
email: "mr_proper_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(maxRecurrenceCount);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.id).toBeDefined();
|
||||
expect(firstBooking.uid).toBeDefined();
|
||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(firstBooking.status).toEqual("accepted");
|
||||
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||
expect(firstBooking.duration).toEqual(60);
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.meetingUrl).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
expect(secondBooking.uid).toBeDefined();
|
||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(secondBooking.status).toEqual("accepted");
|
||||
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||
expect(secondBooking.duration).toEqual(60);
|
||||
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
|
||||
const thirdBooking = data[2];
|
||||
expect(thirdBooking.id).toBeDefined();
|
||||
expect(thirdBooking.uid).toBeDefined();
|
||||
expect(thirdBooking.hosts[0].id).toEqual(user.id);
|
||||
expect(thirdBooking.status).toEqual("accepted");
|
||||
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
|
||||
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
|
||||
expect(thirdBooking.duration).toEqual(60);
|
||||
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(thirdBooking.recurringBookingUid).toBeDefined();
|
||||
expect(thirdBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(thirdBooking.location).toEqual(body.location);
|
||||
expect(thirdBooking.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -307,9 +307,26 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
await handleCancelBooking(bookingRequest);
|
||||
|
||||
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
|
||||
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
|
||||
}
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
}
|
||||
|
||||
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const recurringBookingUid = booking?.recurringEventId;
|
||||
if (!recurringBookingUid) {
|
||||
throw new BadRequestException(
|
||||
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.getBooking(recurringBookingUid);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
|
||||
|
||||
@@ -493,23 +493,38 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
|
||||
bookingUid
|
||||
);
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid);
|
||||
// note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings.
|
||||
// That is what recurringEventId refers to.
|
||||
const isRecurringUid = !!recurringBooking.length;
|
||||
|
||||
if (recurringBooking.length) {
|
||||
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
|
||||
allRemainingBookings = true;
|
||||
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
|
||||
uid = recurringBooking[0].uid;
|
||||
if (isRecurringUid && inputBooking.cancelSubsequentBookings) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking."
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecurringUid) {
|
||||
return {
|
||||
// note(Lauris): set uid as one of the oldest individual recurring ids
|
||||
uid: recurringBooking[0].uid,
|
||||
cancellationReason: inputBooking.cancellationReason,
|
||||
allRemainingBookings: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputBooking.cancelSubsequentBookings) {
|
||||
return {
|
||||
uid: bookingUid,
|
||||
cancellationReason: inputBooking.cancellationReason,
|
||||
cancelSubsequentBookings: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
uid,
|
||||
uid: bookingUid,
|
||||
cancellationReason: inputBooking.cancellationReason,
|
||||
allRemainingBookings,
|
||||
allRemainingBookings: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,23 +532,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
bookingUid: string,
|
||||
inputBooking: CancelSeatedBookingInput_2024_08_13
|
||||
) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
|
||||
bookingUid
|
||||
);
|
||||
|
||||
if (recurringBooking.length) {
|
||||
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
|
||||
allRemainingBookings = true;
|
||||
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
|
||||
uid = recurringBooking[0].uid;
|
||||
}
|
||||
|
||||
// note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings
|
||||
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
||||
return {
|
||||
uid,
|
||||
uid: bookingUid,
|
||||
cancellationReason: "",
|
||||
allRemainingBookings,
|
||||
allRemainingBookings: false,
|
||||
seatReferenceUid: inputBooking.seatUid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3162,6 +3162,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
|
||||
@@ -22,6 +22,14 @@ export class BookingsRepositoryFixture {
|
||||
return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } });
|
||||
}
|
||||
|
||||
async getByRecurringBookingUid(recurringBookingUid: string) {
|
||||
return this.prismaReadClient.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: recurringBookingUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(booking: Prisma.BookingCreateInput) {
|
||||
return this.prismaWriteClient.booking.create({ data: booking });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
@@ -17,11 +19,15 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
|
||||
title: this.calEvent.title,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("AttendeeCancelledEmail", {
|
||||
calEvent: this.calEvent,
|
||||
attendee: this.attendee,
|
||||
}),
|
||||
html: await this.getHtml(this.calEvent, this.attendee),
|
||||
text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"),
|
||||
};
|
||||
}
|
||||
|
||||
protected async getHtml(calEvent: CalendarEvent, attendee: Person) {
|
||||
return await renderEmail("AttendeeCancelledEmail", {
|
||||
calEvent,
|
||||
attendee,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
@@ -20,11 +21,15 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
|
||||
title: this.calEvent.title,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("OrganizerCancelledEmail", {
|
||||
attendee: this.calEvent.organizer,
|
||||
calEvent: this.calEvent,
|
||||
}),
|
||||
html: await this.getHtml(this.calEvent, this.calEvent.organizer),
|
||||
text: this.getTextBody("event_request_cancelled"),
|
||||
};
|
||||
}
|
||||
|
||||
protected async getHtml(calEvent: CalendarEvent, organizer: Person) {
|
||||
return await renderEmail("OrganizerCancelledEmail", {
|
||||
calEvent,
|
||||
attendee: organizer,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,15 @@ export type HandleCancelBookingResponse = {
|
||||
};
|
||||
|
||||
async function handler(req: CustomRequest) {
|
||||
const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid, cancelledBy } =
|
||||
bookingCancelInput.parse(req.body);
|
||||
const {
|
||||
id,
|
||||
uid,
|
||||
allRemainingBookings,
|
||||
cancellationReason,
|
||||
seatReferenceUid,
|
||||
cancelledBy,
|
||||
cancelSubsequentBookings,
|
||||
} = bookingCancelInput.parse(req.body);
|
||||
req.bookingToDelete = await getBookingToDelete(id, uid);
|
||||
const {
|
||||
bookingToDelete,
|
||||
@@ -406,14 +413,19 @@ async function handler(req: CustomRequest) {
|
||||
|
||||
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
|
||||
// action always succeeds even if subsequent integrations fail cancellation.
|
||||
if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) {
|
||||
if (
|
||||
bookingToDelete.eventType?.recurringEvent &&
|
||||
bookingToDelete.recurringEventId &&
|
||||
(allRemainingBookings || cancelSubsequentBookings)
|
||||
) {
|
||||
const recurringEventId = bookingToDelete.recurringEventId;
|
||||
const gte = cancelSubsequentBookings ? bookingToDelete.startTime : new Date();
|
||||
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
gte,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { CalendarService as IcsFeedCalendarService } from "@calcom/app-store/ics
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import type { CredentialDataWithTeamName, LocationOption } from "@calcom/app-store/utils";
|
||||
import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-email";
|
||||
import AttendeeDeclinedEmail from "@calcom/emails/templates/attendee-declined-email";
|
||||
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
|
||||
import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
|
||||
import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-email";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
@@ -194,3 +196,7 @@ export { AttendeeScheduledEmail };
|
||||
export { OrganizerScheduledEmail };
|
||||
|
||||
export { AttendeeDeclinedEmail };
|
||||
|
||||
export { AttendeeCancelledEmail };
|
||||
|
||||
export { OrganizerCancelledEmail };
|
||||
@@ -1,11 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CancelBookingInput_2024_08_13 {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: "User requested cancellation" })
|
||||
cancellationReason?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
description:
|
||||
"For recurring non-seated booking - if true, cancel booking with the bookingUid of the individual recorrence and all recurrences that come after it.",
|
||||
})
|
||||
cancelSubsequentBookings?: boolean;
|
||||
}
|
||||
|
||||
export class CancelSeatedBookingInput_2024_08_13 {
|
||||
|
||||
@@ -327,7 +327,10 @@ export const bookingCreateBodySchemaForApi = extendedBookingCreateBody.merge(
|
||||
export const bookingCancelSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
uid: z.string().optional(),
|
||||
// note(Lauris): allRemainingBookings will cancel all bookings that have start time greater than this moment.
|
||||
allRemainingBookings: z.boolean().optional(),
|
||||
// note(Lauris): cancelSubsequentBookings will cancel all bookings after one specified by id or uid.
|
||||
cancelSubsequentBookings: z.boolean().optional(),
|
||||
cancellationReason: z.string().optional(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
|
||||
|
||||
@@ -3520,7 +3520,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61"
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
@@ -4502,14 +4502,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.60":
|
||||
version: 0.0.60
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.60"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.61":
|
||||
version: 0.0.61
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.61"
|
||||
dependencies:
|
||||
"@calcom/core": "*"
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 802ba5475a5f0e415e0e2fa639628cf4c3642e3de1f8cefb7716fa70d371de71088a329c149387ed5c466300fd83c695be0afadc68a305e8e8d85d40efb9b940
|
||||
checksum: dd04843e877d036476b35c1fed0f5ea6089fc4df7962fb8be63d3c5651ec9527b37890237923f6c6db1fa29eef220ac888cfd7defa845fa269a04cbb70c6cab8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in new issue
Block a user