Skip to content

Commit 2b10414

Browse files
authoredNov 18, 2024
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
1 parent ce0d685 commit 2b10414

File tree

16 files changed

+814
-343
lines changed

16 files changed

+814
-343
lines changed
 

‎apps/api/v2/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"@axiomhq/winston": "^1.2.0",
3030
"@calcom/platform-constants": "*",
3131
"@calcom/platform-enums": "*",
32-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60",
32+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61",
3333
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
3434
"@calcom/platform-types": "*",
3535
"@calcom/platform-utils": "*",

‎apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,12 @@ export class BookingsController_2024_08_13 {
191191
@Post("/:bookingUid/cancel")
192192
@UseGuards(BookingUidGuard)
193193
@HttpCode(HttpStatus.OK)
194-
@ApiOperation({ summary: "Cancel a booking" })
194+
@ApiOperation({
195+
summary: "Cancel a booking",
196+
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
197+
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
198+
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
199+
})
195200
async cancelBooking(
196201
@Req() request: Request,
197202
@Param("bookingUid") bookingUid: string,

‎apps/api/v2/src/ee/bookings/2024-08-13/controllers/reassign-bookings.e2e-spec.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
2525
import { withApiAuth } from "test/utils/withApiAuth";
2626

2727
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
28-
import {
29-
CreateBookingInput_2024_08_13,
30-
BookingOutput_2024_08_13,
31-
RecurringBookingOutput_2024_08_13,
32-
GetBookingsOutput_2024_08_13,
33-
GetSeatedBookingOutput_2024_08_13,
34-
} from "@calcom/platform-types";
28+
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
3529
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
3630

3731
describe("Bookings Endpoints 2024-08-13", () => {

‎apps/api/v2/src/ee/bookings/2024-08-13/controllers/recurring-bookings.e2e-spec.ts

+691
Large diffs are not rendered by default.

‎apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts

-289
Original file line numberDiff line numberDiff line change
@@ -1127,293 +1127,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
11271127
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
11281128
return Array.isArray(data);
11291129
}
1130-
1131-
describe("Recurring bookings", () => {
1132-
let app: INestApplication;
1133-
let organization: Team;
1134-
1135-
let userRepositoryFixture: UserRepositoryFixture;
1136-
let bookingsRepositoryFixture: BookingsRepositoryFixture;
1137-
let schedulesService: SchedulesService_2024_04_15;
1138-
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
1139-
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
1140-
let oAuthClient: PlatformOAuthClient;
1141-
let teamRepositoryFixture: TeamRepositoryFixture;
1142-
1143-
const userEmail = "bookings-controller-e2e@api.com";
1144-
let user: User;
1145-
1146-
const maxRecurrenceCount = 3;
1147-
let recurringEventTypeId: number;
1148-
1149-
beforeAll(async () => {
1150-
const moduleRef = await withApiAuth(
1151-
userEmail,
1152-
Test.createTestingModule({
1153-
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
1154-
})
1155-
)
1156-
.overrideGuard(PermissionsGuard)
1157-
.useValue({
1158-
canActivate: () => true,
1159-
})
1160-
.compile();
1161-
1162-
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
1163-
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
1164-
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
1165-
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
1166-
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
1167-
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
1168-
1169-
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
1170-
oAuthClient = await createOAuthClient(organization.id);
1171-
1172-
user = await userRepositoryFixture.create({
1173-
email: userEmail,
1174-
});
1175-
1176-
const userSchedule: CreateScheduleInput_2024_04_15 = {
1177-
name: "working time",
1178-
timeZone: "Europe/Rome",
1179-
isDefault: true,
1180-
};
1181-
await schedulesService.createUserSchedule(user.id, userSchedule);
1182-
1183-
const recurringEvent = await eventTypesRepositoryFixture.create(
1184-
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
1185-
{
1186-
title: "peer coding recurring",
1187-
slug: "peer-coding-recurring",
1188-
length: 60,
1189-
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
1190-
},
1191-
user.id
1192-
);
1193-
recurringEventTypeId = recurringEvent.id;
1194-
1195-
app = moduleRef.createNestApplication();
1196-
bootstrap(app as NestExpressApplication);
1197-
1198-
await app.init();
1199-
});
1200-
1201-
async function createOAuthClient(organizationId: number) {
1202-
const data = {
1203-
logo: "logo-url",
1204-
name: "name",
1205-
redirectUris: ["http://localhost:5555"],
1206-
permissions: 32,
1207-
};
1208-
const secret = "secret";
1209-
1210-
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
1211-
return client;
1212-
}
1213-
1214-
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
1215-
const recurrenceCount = 1000;
1216-
1217-
const body: CreateRecurringBookingInput_2024_08_13 = {
1218-
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
1219-
eventTypeId: recurringEventTypeId,
1220-
attendee: {
1221-
name: "Mr Proper Recurring",
1222-
email: "mr_proper_recurring@gmail.com",
1223-
timeZone: "Europe/Rome",
1224-
language: "it",
1225-
},
1226-
location: "https://meet.google.com/abc-def-ghi",
1227-
recurrenceCount,
1228-
};
1229-
1230-
return request(app.getHttpServer())
1231-
.post("/v2/bookings")
1232-
.send(body)
1233-
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
1234-
.expect(400);
1235-
});
1236-
1237-
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
1238-
const recurrenceCount = maxRecurrenceCount - 1;
1239-
const body: CreateRecurringBookingInput_2024_08_13 = {
1240-
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
1241-
eventTypeId: recurringEventTypeId,
1242-
attendee: {
1243-
name: "Mr Proper Recurring",
1244-
email: "mr_proper_recurring@gmail.com",
1245-
timeZone: "Europe/Rome",
1246-
language: "it",
1247-
},
1248-
location: "https://meet.google.com/abc-def-ghi",
1249-
recurrenceCount,
1250-
};
1251-
1252-
return request(app.getHttpServer())
1253-
.post("/v2/bookings")
1254-
.send(body)
1255-
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
1256-
.expect(201)
1257-
.then(async (response) => {
1258-
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
1259-
expect(responseBody.status).toEqual(SUCCESS_STATUS);
1260-
expect(responseBody.data).toBeDefined();
1261-
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
1262-
1263-
if (responseDataIsRecurringBooking(responseBody.data)) {
1264-
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
1265-
expect(data.length).toEqual(maxRecurrenceCount - 1);
1266-
1267-
const firstBooking = data[0];
1268-
expect(firstBooking.id).toBeDefined();
1269-
expect(firstBooking.uid).toBeDefined();
1270-
expect(firstBooking.hosts[0].id).toEqual(user.id);
1271-
expect(firstBooking.status).toEqual("accepted");
1272-
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
1273-
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
1274-
expect(firstBooking.duration).toEqual(60);
1275-
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
1276-
expect(firstBooking.attendees[0]).toEqual({
1277-
name: body.attendee.name,
1278-
timeZone: body.attendee.timeZone,
1279-
language: body.attendee.language,
1280-
absent: false,
1281-
});
1282-
expect(firstBooking.location).toEqual(body.location);
1283-
expect(firstBooking.recurringBookingUid).toBeDefined();
1284-
expect(firstBooking.absentHost).toEqual(false);
1285-
1286-
const secondBooking = data[1];
1287-
expect(secondBooking.id).toBeDefined();
1288-
expect(secondBooking.uid).toBeDefined();
1289-
expect(secondBooking.hosts[0].id).toEqual(user.id);
1290-
expect(secondBooking.status).toEqual("accepted");
1291-
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
1292-
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
1293-
expect(secondBooking.duration).toEqual(60);
1294-
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
1295-
expect(secondBooking.recurringBookingUid).toBeDefined();
1296-
expect(secondBooking.attendees[0]).toEqual({
1297-
name: body.attendee.name,
1298-
timeZone: body.attendee.timeZone,
1299-
language: body.attendee.language,
1300-
absent: false,
1301-
});
1302-
expect(secondBooking.location).toEqual(body.location);
1303-
expect(secondBooking.absentHost).toEqual(false);
1304-
} else {
1305-
throw new Error(
1306-
"Invalid response data - expected recurring booking but received non array response"
1307-
);
1308-
}
1309-
});
1310-
});
1311-
1312-
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
1313-
const recurrenceCount = maxRecurrenceCount;
1314-
const body: CreateRecurringBookingInput_2024_08_13 = {
1315-
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
1316-
eventTypeId: recurringEventTypeId,
1317-
attendee: {
1318-
name: "Mr Proper Recurring",
1319-
email: "mr_proper_recurring@gmail.com",
1320-
timeZone: "Europe/Rome",
1321-
language: "it",
1322-
},
1323-
location: "https://meet.google.com/abc-def-ghi",
1324-
recurrenceCount,
1325-
};
1326-
1327-
return request(app.getHttpServer())
1328-
.post("/v2/bookings")
1329-
.send(body)
1330-
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
1331-
.expect(201)
1332-
.then(async (response) => {
1333-
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
1334-
expect(responseBody.status).toEqual(SUCCESS_STATUS);
1335-
expect(responseBody.data).toBeDefined();
1336-
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
1337-
1338-
if (responseDataIsRecurringBooking(responseBody.data)) {
1339-
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
1340-
expect(data.length).toEqual(maxRecurrenceCount);
1341-
1342-
const firstBooking = data[0];
1343-
expect(firstBooking.id).toBeDefined();
1344-
expect(firstBooking.uid).toBeDefined();
1345-
expect(firstBooking.hosts[0].id).toEqual(user.id);
1346-
expect(firstBooking.status).toEqual("accepted");
1347-
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
1348-
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
1349-
expect(firstBooking.duration).toEqual(60);
1350-
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
1351-
expect(firstBooking.attendees[0]).toEqual({
1352-
name: body.attendee.name,
1353-
timeZone: body.attendee.timeZone,
1354-
language: body.attendee.language,
1355-
absent: false,
1356-
});
1357-
expect(firstBooking.location).toEqual(body.location);
1358-
expect(firstBooking.meetingUrl).toEqual(body.location);
1359-
expect(firstBooking.recurringBookingUid).toBeDefined();
1360-
expect(firstBooking.absentHost).toEqual(false);
1361-
1362-
const secondBooking = data[1];
1363-
expect(secondBooking.id).toBeDefined();
1364-
expect(secondBooking.uid).toBeDefined();
1365-
expect(secondBooking.hosts[0].id).toEqual(user.id);
1366-
expect(secondBooking.status).toEqual("accepted");
1367-
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
1368-
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
1369-
expect(secondBooking.duration).toEqual(60);
1370-
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
1371-
expect(secondBooking.recurringBookingUid).toBeDefined();
1372-
expect(secondBooking.attendees[0]).toEqual({
1373-
name: body.attendee.name,
1374-
timeZone: body.attendee.timeZone,
1375-
language: body.attendee.language,
1376-
absent: false,
1377-
});
1378-
expect(secondBooking.location).toEqual(body.location);
1379-
expect(secondBooking.absentHost).toEqual(false);
1380-
1381-
const thirdBooking = data[2];
1382-
expect(thirdBooking.id).toBeDefined();
1383-
expect(thirdBooking.uid).toBeDefined();
1384-
expect(thirdBooking.hosts[0].id).toEqual(user.id);
1385-
expect(thirdBooking.status).toEqual("accepted");
1386-
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
1387-
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
1388-
expect(thirdBooking.duration).toEqual(60);
1389-
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
1390-
expect(thirdBooking.recurringBookingUid).toBeDefined();
1391-
expect(thirdBooking.attendees[0]).toEqual({
1392-
name: body.attendee.name,
1393-
timeZone: body.attendee.timeZone,
1394-
language: body.attendee.language,
1395-
absent: false,
1396-
});
1397-
expect(thirdBooking.location).toEqual(body.location);
1398-
expect(thirdBooking.absentHost).toEqual(false);
1399-
} else {
1400-
throw new Error(
1401-
"Invalid response data - expected recurring booking but received non array response"
1402-
);
1403-
}
1404-
});
1405-
});
1406-
1407-
afterEach(async () => {
1408-
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
1409-
});
1410-
1411-
afterAll(async () => {
1412-
await oauthClientRepositoryFixture.delete(oAuthClient.id);
1413-
await teamRepositoryFixture.delete(organization.id);
1414-
await userRepositoryFixture.deleteByEmail(user.email);
1415-
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
1416-
await app.close();
1417-
});
1418-
});
14191130
});

‎apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts

+17
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,26 @@ export class BookingsService_2024_08_13 {
307307

308308
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
309309
await handleCancelBooking(bookingRequest);
310+
311+
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
312+
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
313+
}
314+
310315
return this.getBooking(bookingUid);
311316
}
312317

318+
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
319+
const booking = await this.bookingsRepository.getByUid(bookingUid);
320+
const recurringBookingUid = booking?.recurringEventId;
321+
if (!recurringBookingUid) {
322+
throw new BadRequestException(
323+
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
324+
);
325+
}
326+
327+
return await this.getBooking(recurringBookingUid);
328+
}
329+
313330
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
314331
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
315332

0 commit comments

Comments
 (0)