refactor: v2 booking responses schema (#20030)

* refactor: when rescheduling fallback on attendee if responses invalid

* chore: submit auto generated docs

* fix: slots event type metadata parsing

* fix: booking responses without email with attendee phone

* refactor

* refactor: test

* remove unused import

* refactor

* test: slots variable duration
This commit is contained in:
Lauris Skraucis
2025-03-19 16:32:00 +02:00
committed by GitHub
parent 232f9d288c
commit 8102192987
7 changed files with 300 additions and 30 deletions
@@ -1,6 +1,7 @@
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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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";
@@ -31,6 +32,7 @@ import {
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
@@ -66,6 +68,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`;
let phoneBasedBooking: BookingOutput_2024_08_13;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
@@ -408,12 +412,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
it("should create a phone based booking", async () => {
const phoneNumber = "+919876543210";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: phoneOnlyEventTypeId,
attendee: {
name: "alice",
phoneNumber: "+919876543210",
phoneNumber,
timeZone: "Europe/Madrid",
language: "es",
},
@@ -453,6 +458,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses.attendeePhoneNumber).toEqual(phoneNumber);
phoneBasedBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
@@ -619,6 +626,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("reschedule", () => {
it("should should reschedule phone based booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${phoneBasedBooking.uid}/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);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 14, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(phoneOnlyEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: phoneBasedBooking.attendees[0].name,
email: phoneBasedBooking.attendees[0].email,
phoneNumber: phoneBasedBooking.attendees[0].phoneNumber,
timeZone: phoneBasedBooking.attendees[0].timeZone,
language: phoneBasedBooking.attendees[0].language,
absent: false,
});
expect(data.meetingUrl).toEqual(phoneBasedBooking.meetingUrl);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses.attendeePhoneNumber).toEqual(
phoneBasedBooking.bookingFieldsResponses.attendeePhoneNumber
);
phoneBasedBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
@@ -1949,6 +1949,95 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("rescheduling booking with invalid booking responses", () => {
it("should reschedule", async () => {
const email = `user-bookings-attendee-${randomString(10)}@gmail.com`;
const username = `user-bookings-attendee-${randomString(10)}`;
const locale = "it";
const timeZone = "Europe/Rome";
const attendee = await userRepositoryFixture.create({
email,
username,
locale,
timeZone,
});
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString(10)}`,
title: "booking title",
startTime: "2060-09-05T11:00:00.000Z",
endTime: "2060-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
metadata: {},
responses: "null",
user: {
connect: {
id: user.id,
},
},
attendees: {
create: {
email: email,
name: username,
timeZone,
locale,
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${booking.uid}/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);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(booking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("accepted");
expect(data.eventTypeId).toEqual(booking.eventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: attendee.username,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: attendee.username,
email: attendee.email,
rescheduledReason: body.reschedulingReason,
guests: [],
});
await bookingsRepositoryFixture.deleteById(data.id);
await userRepositoryFixture.delete(attendee.id);
});
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -7,6 +7,8 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
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";
@@ -41,7 +43,7 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input";
import { EventType, PlatformOAuthClient } from "@calcom/prisma/client";
import { EventType } from "@calcom/prisma/client";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
@@ -429,6 +431,7 @@ export class InputBookingsService_2024_08_13 {
...(inputBooking.bookingFieldsResponses || {}),
name: inputBooking.attendee.name,
email: attendeeEmail,
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
guests,
location,
},
@@ -567,8 +570,19 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
}
const bookingResponses = bookingResponsesSchema.parse(booking.responses);
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
const bookingResponses = safeParse(
bookingResponsesSchema,
booking.responses,
defaultBookingResponses,
false
);
const bookingResponsesMissing =
bookingResponses.name === defaultBookingResponses.name &&
bookingResponses.email === defaultBookingResponses.email;
const attendee = bookingResponsesMissing
? booking.attendees[0]
: booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
if (!attendee) {
throw new NotFoundException(
@@ -576,6 +590,12 @@ export class InputBookingsService_2024_08_13 {
);
}
if (bookingResponsesMissing) {
bookingResponses.name = attendee.name;
bookingResponses.email = attendee.email;
bookingResponses.attendeePhoneNumber = attendee.phoneNumber || undefined;
}
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
const endTime = startTime.plus({ minutes: eventType.length });
@@ -27,6 +27,7 @@ import { Booking, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
email: z.string(),
attendeePhoneNumber: z.string().optional(),
name: z.union([
z.string(),
z.object({
@@ -45,6 +46,7 @@ export const seatedBookingDataSchema = z
responses: z
.object({
email: z.string(),
attendeePhoneNumber: z.string().optional(),
name: z.union([
z.string(),
z.object({
@@ -2,7 +2,7 @@ export const defaultBookingResponses = {
name: "unknown",
email: "unknown",
guests: [],
rescheduleReason: "unknown",
rescheduledReason: "unknown",
};
export const defaultBookingMetadata = { videoCallUrl: "unknown" };
@@ -75,6 +75,9 @@ describe("Slots 2024-09-04 Endpoints", () => {
const seatedEventTypeSlug = `slots-2024-09-04-seated-event-type-${randomString()}`;
let seatedEventType: EventType;
const variableLengthEventTypeSlug = `slots-2024-09-04-variable-length-event-type-${randomString()}`;
let variableLengthEventType: EventType;
let reservedSlot: ReserveSlotOutputData_2024_09_04;
beforeAll(async () => {
@@ -168,6 +171,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
seatedEventType = seatedEvent;
const variableLengthEvent = await eventTypesRepositoryFixture.create(
{
title: "frisbee match",
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
length: 15,
metadata: { multipleDuration: [15, 30, 45, 60] },
},
user.id
);
variableLengthEventType = variableLengthEvent;
team = await teamRepositoryFixture.create({
name: `slots-2024-09-04-team-${randomString()}`,
isOrganization: false,
@@ -1246,6 +1260,90 @@ describe("Slots 2024-09-04 Endpoints", () => {
await bookingsRepositoryFixture.deleteById(booking.id);
});
describe("variable length", () => {
it("should not be able to reserve a slot for variable length event type with invalid duration", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: slotStartTime,
slotDuration: 1000,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(400);
expect(reserveResponse.body.error.message).toEqual(
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60"
);
});
it("should reserve a slot with slot duration for variable event type length", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotDuration = 60;
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: slotStartTime,
slotDuration,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedSlot.slotEnd).toEqual(
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
if (!responseReservedSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
const response = await request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== slotStartTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt);
}
clear();
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(unrelatedUser.email);
@@ -21,9 +21,11 @@ import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
const eventTypeMetadataSchema = z.object({
multipleDuration: z.number().array().optional(),
});
const eventTypeMetadataSchema = z
.object({
multipleDuration: z.number().array().optional(),
})
.nullable();
const DEFAULT_RESERVATION_DURATION = 5;
@@ -89,17 +91,8 @@ export class SlotsService_2024_09_04 {
throw new BadRequestException("Invalid start date");
}
const metadata = eventTypeMetadataSchema.parse(eventType);
if (
input.slotDuration &&
metadata.multipleDuration &&
!metadata.multipleDuration.includes(input.slotDuration)
) {
throw new BadRequestException(
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
", "
)}`
);
if (input.slotDuration) {
this.validateSlotDuration(eventType, input.slotDuration);
}
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });
@@ -160,6 +153,23 @@ export class SlotsService_2024_09_04 {
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
if (!eventTypeMetadata?.multipleDuration) {
throw new BadRequestException(
"You passed 'slotDuration' but this event type is not a variable length event type."
);
}
if (!eventTypeMetadata.multipleDuration.includes(inputSlotDuration)) {
throw new BadRequestException(
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${eventTypeMetadata.multipleDuration.join(
", "
)}`
);
}
}
async canSpecifyCustomReservationDuration(authUserId: number, eventType: EventType) {
if (eventType.userId) {
return await this.canSpecifyCustomReservationDurationIndividualEvent(authUserId, eventType.userId);
@@ -214,17 +224,8 @@ export class SlotsService_2024_09_04 {
throw new BadRequestException("Invalid start date");
}
const metadata = eventTypeMetadataSchema.parse(eventType);
if (
input.slotDuration &&
metadata.multipleDuration &&
!metadata.multipleDuration.includes(input.slotDuration)
) {
throw new BadRequestException(
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
", "
)}`
);
if (input.slotDuration) {
this.validateSlotDuration(eventType, input.slotDuration);
}
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });