diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts new file mode 100644 index 0000000000..45e3102af0 --- /dev/null +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts @@ -0,0 +1,490 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SlotsModule } from "@/modules/slots/slots.module"; +import { TokensModule } from "@/modules/tokens/tokens.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 { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomNumber } from "test/utils/randomNumber"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +const expectedSlotsUTC = { + slots: { + "2050-09-05": [ + { time: "2050-09-05T07:00:00.000Z" }, + { time: "2050-09-05T08:00:00.000Z" }, + { time: "2050-09-05T09:00:00.000Z" }, + { time: "2050-09-05T10:00:00.000Z" }, + { time: "2050-09-05T11:00:00.000Z" }, + { time: "2050-09-05T12:00:00.000Z" }, + { time: "2050-09-05T13:00:00.000Z" }, + { time: "2050-09-05T14:00:00.000Z" }, + ], + "2050-09-06": [ + { time: "2050-09-06T07:00:00.000Z" }, + { time: "2050-09-06T08:00:00.000Z" }, + { time: "2050-09-06T09:00:00.000Z" }, + { time: "2050-09-06T10:00:00.000Z" }, + { time: "2050-09-06T11:00:00.000Z" }, + { time: "2050-09-06T12:00:00.000Z" }, + { time: "2050-09-06T13:00:00.000Z" }, + { time: "2050-09-06T14:00:00.000Z" }, + ], + "2050-09-07": [ + { time: "2050-09-07T07:00:00.000Z" }, + { time: "2050-09-07T08:00:00.000Z" }, + { time: "2050-09-07T09:00:00.000Z" }, + { time: "2050-09-07T10:00:00.000Z" }, + { time: "2050-09-07T11:00:00.000Z" }, + { time: "2050-09-07T12:00:00.000Z" }, + { time: "2050-09-07T13:00:00.000Z" }, + { time: "2050-09-07T14:00:00.000Z" }, + ], + "2050-09-08": [ + { time: "2050-09-08T07:00:00.000Z" }, + { time: "2050-09-08T08:00:00.000Z" }, + { time: "2050-09-08T09:00:00.000Z" }, + { time: "2050-09-08T10:00:00.000Z" }, + { time: "2050-09-08T11:00:00.000Z" }, + { time: "2050-09-08T12:00:00.000Z" }, + { time: "2050-09-08T13:00:00.000Z" }, + { time: "2050-09-08T14:00:00.000Z" }, + ], + "2050-09-09": [ + { time: "2050-09-09T07:00:00.000Z" }, + { time: "2050-09-09T08:00:00.000Z" }, + { time: "2050-09-09T09:00:00.000Z" }, + { time: "2050-09-09T10:00:00.000Z" }, + { time: "2050-09-09T11:00:00.000Z" }, + { time: "2050-09-09T12:00:00.000Z" }, + { time: "2050-09-09T13:00:00.000Z" }, + { time: "2050-09-09T14:00:00.000Z" }, + ], + }, +}; + +const expectedSlotsRome = { + slots: { + "2050-09-05": [ + { time: "2050-09-05T09:00:00.000+02:00" }, + { time: "2050-09-05T10:00:00.000+02:00" }, + { time: "2050-09-05T11:00:00.000+02:00" }, + { time: "2050-09-05T12:00:00.000+02:00" }, + { time: "2050-09-05T13:00:00.000+02:00" }, + { time: "2050-09-05T14:00:00.000+02:00" }, + { time: "2050-09-05T15:00:00.000+02:00" }, + { time: "2050-09-05T16:00:00.000+02:00" }, + ], + "2050-09-06": [ + { time: "2050-09-06T09:00:00.000+02:00" }, + { time: "2050-09-06T10:00:00.000+02:00" }, + { time: "2050-09-06T11:00:00.000+02:00" }, + { time: "2050-09-06T12:00:00.000+02:00" }, + { time: "2050-09-06T13:00:00.000+02:00" }, + { time: "2050-09-06T14:00:00.000+02:00" }, + { time: "2050-09-06T15:00:00.000+02:00" }, + { time: "2050-09-06T16:00:00.000+02:00" }, + ], + "2050-09-07": [ + { time: "2050-09-07T09:00:00.000+02:00" }, + { time: "2050-09-07T10:00:00.000+02:00" }, + { time: "2050-09-07T11:00:00.000+02:00" }, + { time: "2050-09-07T12:00:00.000+02:00" }, + { time: "2050-09-07T13:00:00.000+02:00" }, + { time: "2050-09-07T14:00:00.000+02:00" }, + { time: "2050-09-07T15:00:00.000+02:00" }, + { time: "2050-09-07T16:00:00.000+02:00" }, + ], + "2050-09-08": [ + { time: "2050-09-08T09:00:00.000+02:00" }, + { time: "2050-09-08T10:00:00.000+02:00" }, + { time: "2050-09-08T11:00:00.000+02:00" }, + { time: "2050-09-08T12:00:00.000+02:00" }, + { time: "2050-09-08T13:00:00.000+02:00" }, + { time: "2050-09-08T14:00:00.000+02:00" }, + { time: "2050-09-08T15:00:00.000+02:00" }, + { time: "2050-09-08T16:00:00.000+02:00" }, + ], + "2050-09-09": [ + { time: "2050-09-09T09:00:00.000+02:00" }, + { time: "2050-09-09T10:00:00.000+02:00" }, + { time: "2050-09-09T11:00:00.000+02:00" }, + { time: "2050-09-09T12:00:00.000+02:00" }, + { time: "2050-09-09T13:00:00.000+02:00" }, + { time: "2050-09-09T14:00:00.000+02:00" }, + { time: "2050-09-09T15:00:00.000+02:00" }, + { time: "2050-09-09T16:00:00.000+02:00" }, + ], + }, +}; + +const expectedSlotsUTCRange = { + slots: { + "2050-09-05": [ + { startTime: "2050-09-05T07:00:00.000Z", endTime: "2050-09-05T08:00:00.000Z" }, + { startTime: "2050-09-05T08:00:00.000Z", endTime: "2050-09-05T09:00:00.000Z" }, + { startTime: "2050-09-05T09:00:00.000Z", endTime: "2050-09-05T10:00:00.000Z" }, + { startTime: "2050-09-05T10:00:00.000Z", endTime: "2050-09-05T11:00:00.000Z" }, + { startTime: "2050-09-05T11:00:00.000Z", endTime: "2050-09-05T12:00:00.000Z" }, + { startTime: "2050-09-05T12:00:00.000Z", endTime: "2050-09-05T13:00:00.000Z" }, + { startTime: "2050-09-05T13:00:00.000Z", endTime: "2050-09-05T14:00:00.000Z" }, + { startTime: "2050-09-05T14:00:00.000Z", endTime: "2050-09-05T15:00:00.000Z" }, + ], + "2050-09-06": [ + { startTime: "2050-09-06T07:00:00.000Z", endTime: "2050-09-06T08:00:00.000Z" }, + { startTime: "2050-09-06T08:00:00.000Z", endTime: "2050-09-06T09:00:00.000Z" }, + { startTime: "2050-09-06T09:00:00.000Z", endTime: "2050-09-06T10:00:00.000Z" }, + { startTime: "2050-09-06T10:00:00.000Z", endTime: "2050-09-06T11:00:00.000Z" }, + { startTime: "2050-09-06T11:00:00.000Z", endTime: "2050-09-06T12:00:00.000Z" }, + { startTime: "2050-09-06T12:00:00.000Z", endTime: "2050-09-06T13:00:00.000Z" }, + { startTime: "2050-09-06T13:00:00.000Z", endTime: "2050-09-06T14:00:00.000Z" }, + { startTime: "2050-09-06T14:00:00.000Z", endTime: "2050-09-06T15:00:00.000Z" }, + ], + "2050-09-07": [ + { startTime: "2050-09-07T07:00:00.000Z", endTime: "2050-09-07T08:00:00.000Z" }, + { startTime: "2050-09-07T08:00:00.000Z", endTime: "2050-09-07T09:00:00.000Z" }, + { startTime: "2050-09-07T09:00:00.000Z", endTime: "2050-09-07T10:00:00.000Z" }, + { startTime: "2050-09-07T10:00:00.000Z", endTime: "2050-09-07T11:00:00.000Z" }, + { startTime: "2050-09-07T11:00:00.000Z", endTime: "2050-09-07T12:00:00.000Z" }, + { startTime: "2050-09-07T12:00:00.000Z", endTime: "2050-09-07T13:00:00.000Z" }, + { startTime: "2050-09-07T13:00:00.000Z", endTime: "2050-09-07T14:00:00.000Z" }, + { startTime: "2050-09-07T14:00:00.000Z", endTime: "2050-09-07T15:00:00.000Z" }, + ], + "2050-09-08": [ + { startTime: "2050-09-08T07:00:00.000Z", endTime: "2050-09-08T08:00:00.000Z" }, + { startTime: "2050-09-08T08:00:00.000Z", endTime: "2050-09-08T09:00:00.000Z" }, + { startTime: "2050-09-08T09:00:00.000Z", endTime: "2050-09-08T10:00:00.000Z" }, + { startTime: "2050-09-08T10:00:00.000Z", endTime: "2050-09-08T11:00:00.000Z" }, + { startTime: "2050-09-08T11:00:00.000Z", endTime: "2050-09-08T12:00:00.000Z" }, + { startTime: "2050-09-08T12:00:00.000Z", endTime: "2050-09-08T13:00:00.000Z" }, + { startTime: "2050-09-08T13:00:00.000Z", endTime: "2050-09-08T14:00:00.000Z" }, + { startTime: "2050-09-08T14:00:00.000Z", endTime: "2050-09-08T15:00:00.000Z" }, + ], + "2050-09-09": [ + { startTime: "2050-09-09T07:00:00.000Z", endTime: "2050-09-09T08:00:00.000Z" }, + { startTime: "2050-09-09T08:00:00.000Z", endTime: "2050-09-09T09:00:00.000Z" }, + { startTime: "2050-09-09T09:00:00.000Z", endTime: "2050-09-09T10:00:00.000Z" }, + { startTime: "2050-09-09T10:00:00.000Z", endTime: "2050-09-09T11:00:00.000Z" }, + { startTime: "2050-09-09T11:00:00.000Z", endTime: "2050-09-09T12:00:00.000Z" }, + { startTime: "2050-09-09T12:00:00.000Z", endTime: "2050-09-09T13:00:00.000Z" }, + { startTime: "2050-09-09T13:00:00.000Z", endTime: "2050-09-09T14:00:00.000Z" }, + { startTime: "2050-09-09T14:00:00.000Z", endTime: "2050-09-09T15:00:00.000Z" }, + ], + }, +}; + +const expectedSlotsRomeRange = { + slots: { + "2050-09-05": [ + { startTime: "2050-09-05T09:00:00.000+02:00", endTime: "2050-09-05T10:00:00.000+02:00" }, + { startTime: "2050-09-05T10:00:00.000+02:00", endTime: "2050-09-05T11:00:00.000+02:00" }, + { startTime: "2050-09-05T11:00:00.000+02:00", endTime: "2050-09-05T12:00:00.000+02:00" }, + { startTime: "2050-09-05T12:00:00.000+02:00", endTime: "2050-09-05T13:00:00.000+02:00" }, + { startTime: "2050-09-05T13:00:00.000+02:00", endTime: "2050-09-05T14:00:00.000+02:00" }, + { startTime: "2050-09-05T14:00:00.000+02:00", endTime: "2050-09-05T15:00:00.000+02:00" }, + { startTime: "2050-09-05T15:00:00.000+02:00", endTime: "2050-09-05T16:00:00.000+02:00" }, + { startTime: "2050-09-05T16:00:00.000+02:00", endTime: "2050-09-05T17:00:00.000+02:00" }, + ], + "2050-09-06": [ + { startTime: "2050-09-06T09:00:00.000+02:00", endTime: "2050-09-06T10:00:00.000+02:00" }, + { startTime: "2050-09-06T10:00:00.000+02:00", endTime: "2050-09-06T11:00:00.000+02:00" }, + { startTime: "2050-09-06T11:00:00.000+02:00", endTime: "2050-09-06T12:00:00.000+02:00" }, + { startTime: "2050-09-06T12:00:00.000+02:00", endTime: "2050-09-06T13:00:00.000+02:00" }, + { startTime: "2050-09-06T13:00:00.000+02:00", endTime: "2050-09-06T14:00:00.000+02:00" }, + { startTime: "2050-09-06T14:00:00.000+02:00", endTime: "2050-09-06T15:00:00.000+02:00" }, + { startTime: "2050-09-06T15:00:00.000+02:00", endTime: "2050-09-06T16:00:00.000+02:00" }, + { startTime: "2050-09-06T16:00:00.000+02:00", endTime: "2050-09-06T17:00:00.000+02:00" }, + ], + "2050-09-07": [ + { startTime: "2050-09-07T09:00:00.000+02:00", endTime: "2050-09-07T10:00:00.000+02:00" }, + { startTime: "2050-09-07T10:00:00.000+02:00", endTime: "2050-09-07T11:00:00.000+02:00" }, + { startTime: "2050-09-07T11:00:00.000+02:00", endTime: "2050-09-07T12:00:00.000+02:00" }, + { startTime: "2050-09-07T12:00:00.000+02:00", endTime: "2050-09-07T13:00:00.000+02:00" }, + { startTime: "2050-09-07T13:00:00.000+02:00", endTime: "2050-09-07T14:00:00.000+02:00" }, + { startTime: "2050-09-07T14:00:00.000+02:00", endTime: "2050-09-07T15:00:00.000+02:00" }, + { startTime: "2050-09-07T15:00:00.000+02:00", endTime: "2050-09-07T16:00:00.000+02:00" }, + { startTime: "2050-09-07T16:00:00.000+02:00", endTime: "2050-09-07T17:00:00.000+02:00" }, + ], + "2050-09-08": [ + { startTime: "2050-09-08T09:00:00.000+02:00", endTime: "2050-09-08T10:00:00.000+02:00" }, + { startTime: "2050-09-08T10:00:00.000+02:00", endTime: "2050-09-08T11:00:00.000+02:00" }, + { startTime: "2050-09-08T11:00:00.000+02:00", endTime: "2050-09-08T12:00:00.000+02:00" }, + { startTime: "2050-09-08T12:00:00.000+02:00", endTime: "2050-09-08T13:00:00.000+02:00" }, + { startTime: "2050-09-08T13:00:00.000+02:00", endTime: "2050-09-08T14:00:00.000+02:00" }, + { startTime: "2050-09-08T14:00:00.000+02:00", endTime: "2050-09-08T15:00:00.000+02:00" }, + { startTime: "2050-09-08T15:00:00.000+02:00", endTime: "2050-09-08T16:00:00.000+02:00" }, + { startTime: "2050-09-08T16:00:00.000+02:00", endTime: "2050-09-08T17:00:00.000+02:00" }, + ], + "2050-09-09": [ + { startTime: "2050-09-09T09:00:00.000+02:00", endTime: "2050-09-09T10:00:00.000+02:00" }, + { startTime: "2050-09-09T10:00:00.000+02:00", endTime: "2050-09-09T11:00:00.000+02:00" }, + { startTime: "2050-09-09T11:00:00.000+02:00", endTime: "2050-09-09T12:00:00.000+02:00" }, + { startTime: "2050-09-09T12:00:00.000+02:00", endTime: "2050-09-09T13:00:00.000+02:00" }, + { startTime: "2050-09-09T13:00:00.000+02:00", endTime: "2050-09-09T14:00:00.000+02:00" }, + { startTime: "2050-09-09T14:00:00.000+02:00", endTime: "2050-09-09T15:00:00.000+02:00" }, + { startTime: "2050-09-09T15:00:00.000+02:00", endTime: "2050-09-09T16:00:00.000+02:00" }, + { startTime: "2050-09-09T16:00:00.000+02:00", endTime: "2050-09-09T17:00:00.000+02:00" }, + ], + }, +}; + +describe("Slots Endpoints", () => { + describe("Individual user slots", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let schedulesService: SchedulesService_2024_06_11; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + + const userEmail = `slots-${randomNumber()}-controller-e2e@api.com`; + const userName = "bob"; + let user: User; + let eventTypeId: number; + let eventTypeSlug: string; + let reservedSlotUid: string; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [ + AppModule, + PrismaModule, + UsersModule, + TokensModule, + SchedulesModule_2024_06_11, + SlotsModule, + ], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_06_11); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + name: "bob slot", + username: userName, + }); + + // nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone + await schedulesService.createUserDefaultSchedule(user.id, "Europe/Rome"); + + const event = await eventTypesRepositoryFixture.create( + { title: "frisbee match", slug: "frisbee-match", length: 60 }, + user.id + ); + eventTypeId = event.id; + eventTypeSlug = event.slug; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should get slots in UTC by event type id", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTC, + }); + }); + }); + + it("should get slots in specified time zone by event type id", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRome, + }); + }); + }); + + it("should get slots in UTC by event type id in range format", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTCRange, + }); + }); + }); + + it("should get slots in specified time zone by event type id in range format", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome&slotFormat=range` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRomeRange, + }); + }); + }); + + it("should get slots in UTC by event type slug", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTC, + }); + }); + }); + + it("should get slots in specified time zone by event type slug", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRome, + }); + }); + }); + + it("should reserve a slot and it should not appear in available slots", async () => { + const slotStartTime = "2050-09-05T10:00:00.000Z"; + const reserveResponse = await request(app.getHttpServer()) + .post(`/api/v2/slots/reserve`) + .send({ + eventTypeId, + slotUtcStartDate: slotStartTime, + slotUtcEndDate: "2050-09-05T11:00:00.000Z", + }) + .expect(201); + + const reserveResponseBody = reserveResponse.body; + expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS); + const uid: string = reserveResponseBody.data; + expect(uid).toBeDefined(); + if (!uid) { + throw new Error("Reserved slot uid is undefined"); + } + reservedSlotUid = uid; + + const response = await request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200); + + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots.slots); + expect(days.length).toEqual(5); + + const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter( + (slot) => slot.time !== slotStartTime + ); + expect(slots).toEqual({ + slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 }, + }); + }); + + it("should delete reserved slot", async () => { + await request(app.getHttpServer()) + .delete(`/api/v2/slots/selected-slot?uid=${reservedSlotUid}`) + .expect(200); + }); + + it("should do a booking and slot should not be available at that time", async () => { + const startTime = "2050-09-05T11:00:00.000Z"; + await bookingsRepositoryFixture.create({ + uid: `booking-uid-${eventTypeId}`, + title: "booking title", + startTime, + endTime: "2050-09-05T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const response = await request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200); + + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots.slots); + expect(days.length).toEqual(5); + + const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter( + (slot) => slot.time !== startTime + ); + expect(slots).toEqual({ + slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 }, + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts index b381e936ca..feb96b0740 100644 --- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -1,11 +1,11 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { SlotsOutputService } from "@/modules/slots/services/slots-output.service"; import { SlotsService } from "@/modules/slots/services/slots.service"; import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common"; import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; import { Response as ExpressResponse, Request as ExpressRequest } from "express"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { SlotFormat } from "@calcom/platform-enums"; import { getAvailableSlots } from "@calcom/platform-libraries"; import type { AvailableSlotsType } from "@calcom/platform-libraries"; import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types"; @@ -17,7 +17,10 @@ import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types"; }) @DocsTags("Slots") export class SlotsController { - constructor(private readonly slotsService: SlotsService) {} + constructor( + private readonly slotsService: SlotsService, + private readonly slotsOutputService: SlotsOutputService + ) {} @Post("/reserve") @ApiCreatedResponse({ @@ -159,19 +162,17 @@ export class SlotsController { }, }); - const transformedSlots = - query.slotFormat === SlotFormat.Range - ? await this.slotsService.formatSlots( - availableSlots, - query.duration, - query.eventTypeId, - query.slotFormat - ) - : availableSlots.slots; + const { slots } = await this.slotsOutputService.getOutputSlots( + availableSlots, + query.duration, + query.eventTypeId, + query.slotFormat, + query.timeZone + ); return { data: { - slots: transformedSlots, + slots, }, status: SUCCESS_STATUS, }; diff --git a/apps/api/v2/src/modules/slots/services/slots-output.service.ts b/apps/api/v2/src/modules/slots/services/slots-output.service.ts new file mode 100644 index 0000000000..6bfb7261ac --- /dev/null +++ b/apps/api/v2/src/modules/slots/services/slots-output.service.ts @@ -0,0 +1,96 @@ +import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; +import { Injectable, BadRequestException } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { SlotFormat } from "@calcom/platform-enums"; + +type TimeSlots = { slots: Record }; +type RangeSlots = { slots: Record }; + +@Injectable() +export class SlotsOutputService { + constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {} + + async getOutputSlots( + availableSlots: TimeSlots, + duration?: number, + eventTypeId?: number, + slotFormat?: SlotFormat, + timeZone?: string + ): Promise { + if (!slotFormat) { + return timeZone ? this.setTimeZone(availableSlots, timeZone) : availableSlots; + } + + const formattedSlots = await this.formatSlots(availableSlots, duration, eventTypeId, slotFormat); + return timeZone ? this.setTimeZoneRange(formattedSlots, timeZone) : formattedSlots; + } + + private setTimeZone(slots: TimeSlots, timeZone: string): TimeSlots { + const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => { + acc[date] = daySlots.map((slot) => ({ + time: DateTime.fromISO(slot.time).setZone(timeZone).toISO() || "unknown-time", + })); + return acc; + }, {} as Record); + + return { slots: formattedSlots }; + } + + private setTimeZoneRange(slots: RangeSlots, timeZone: string): RangeSlots { + const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => { + acc[date] = daySlots.map((slot) => ({ + startTime: DateTime.fromISO(slot.startTime).setZone(timeZone).toISO() || "unknown-start-time", + endTime: DateTime.fromISO(slot.endTime).setZone(timeZone).toISO() || "unknown-end-time", + })); + return acc; + }, {} as Record); + + return { slots: formattedSlots }; + } + + private async formatSlots( + availableSlots: TimeSlots, + duration?: number, + eventTypeId?: number, + slotFormat?: SlotFormat + ): Promise { + if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) { + throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'"); + } + + const slotDuration = await this.getDuration(duration, eventTypeId); + + const slots = Object.entries(availableSlots.slots).reduce< + Record + >((acc, [date, slots]) => { + acc[date] = (slots as { time: string }[]).map((slot) => { + const startTime = new Date(slot.time); + const endTime = new Date(startTime.getTime() + slotDuration * 60000); + return { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + }); + return acc; + }, {}); + + return { slots }; + } + + private async getDuration(duration?: number, eventTypeId?: number): Promise { + if (duration) { + return duration; + } + + if (eventTypeId) { + const eventType = await this.eventTypesRepository.getEventTypeWithDuration(eventTypeId); + if (!eventType) { + throw new Error("Event type not found"); + } + return eventType.length; + } + + throw new Error("duration or eventTypeId is required"); + } +} diff --git a/apps/api/v2/src/modules/slots/services/slots.service.ts b/apps/api/v2/src/modules/slots/services/slots.service.ts index 7d076ec6a4..6ea24916de 100644 --- a/apps/api/v2/src/modules/slots/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/services/slots.service.ts @@ -1,6 +1,7 @@ import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; import { SlotsRepository } from "@/modules/slots/slots.repository"; import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; +import { DateTime } from "luxon"; import { v4 as uuid } from "uuid"; import { SlotFormat } from "@calcom/platform-enums"; @@ -55,51 +56,4 @@ export class SlotsService { const event = await this.eventTypeRepo.getEventTypeById(eventTypeId); return !!event?.teamId; } - - async getEventTypeWithDuration(eventTypeId: number) { - return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId); - } - - async getDuration(duration?: number, eventTypeId?: number): Promise { - if (duration) { - return duration; - } - - if (eventTypeId) { - const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId); - if (!eventType) { - throw new Error("Event type not found"); - } - return eventType.length; - } - - throw new Error("duration or eventTypeId is required"); - } - - async formatSlots( - availableSlots: { slots: Record }, - duration?: number, - eventTypeId?: number, - slotFormat?: SlotFormat - ): Promise> { - if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) { - throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'"); - } - - const slotDuration = await this.getDuration(duration, eventTypeId); - - return Object.entries(availableSlots.slots).reduce< - Record - >((acc, [date, slots]) => { - acc[date] = (slots as { time: string }[]).map((slot) => { - const startTime = new Date(slot.time); - const endTime = new Date(startTime.getTime() + slotDuration * 60000); - return { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }; - }); - return acc; - }, {}); - } } diff --git a/apps/api/v2/src/modules/slots/slots.module.ts b/apps/api/v2/src/modules/slots/slots.module.ts index e85b49af6a..bad7120e81 100644 --- a/apps/api/v2/src/modules/slots/slots.module.ts +++ b/apps/api/v2/src/modules/slots/slots.module.ts @@ -1,13 +1,14 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { SlotsController } from "@/modules/slots/controllers/slots.controller"; +import { SlotsOutputService } from "@/modules/slots/services/slots-output.service"; import { SlotsService } from "@/modules/slots/services/slots.service"; import { SlotsRepository } from "@/modules/slots/slots.repository"; import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, EventTypesModule_2024_04_15], - providers: [SlotsRepository, SlotsService], + providers: [SlotsRepository, SlotsService, SlotsOutputService], controllers: [SlotsController], exports: [SlotsService], }) diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 0cb5926d0b..db5449c20f 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -5139,7 +5139,7 @@ "name": "eventTypeSlug", "required": false, "in": "query", - "description": "Slug of the event type for which slots are being fetched.", + "description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`", "schema": { "type": "string" } @@ -5148,7 +5148,8 @@ "name": "usernameList", "required": false, "in": "query", - "description": "Only for dynamic events - list of usernames for which slots are being fetched.", + "description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.", + "example": "usernameList[]=bob", "schema": { "type": "array", "items": { diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts index 2d0e57c20d..e27ff90711 100644 --- a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -38,6 +38,11 @@ export class EventTypesRepositoryFixture { return this.prismaWriteClient.eventType.create({ data: { ...data, + users: { + connect: { + id: userId, + }, + }, owner: { connect: { id: userId, diff --git a/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts new file mode 100644 index 0000000000..b9b5150eff --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts @@ -0,0 +1,18 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { SelectedSlots } from "@prisma/client"; + +export class SelectedSlotsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async deleteByUId(uid: SelectedSlots["uid"]) { + return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } }); + } +} diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 3df7ca3cf8..49a8ad637e 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -4879,7 +4879,7 @@ "name": "eventTypeSlug", "required": false, "in": "query", - "description": "Slug of the event type for which slots are being fetched.", + "description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`", "schema": { "type": "string" } @@ -4888,7 +4888,8 @@ "name": "usernameList", "required": false, "in": "query", - "description": "Only for dynamic events - list of usernames for which slots are being fetched.", + "description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.", + "example": "usernameList[]=bob", "schema": { "type": "array", "items": { diff --git a/packages/platform/types/slots.ts b/packages/platform/types/slots.ts index aee9b67f98..3a819f5cbf 100644 --- a/packages/platform/types/slots.ts +++ b/packages/platform/types/slots.ts @@ -37,7 +37,10 @@ export class GetAvailableSlotsInput { @IsString() @IsOptional() - @ApiPropertyOptional({ description: "Slug of the event type for which slots are being fetched." }) + @ApiPropertyOptional({ + description: + "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`", + }) eventTypeSlug?: string; @IsArray() @@ -45,7 +48,9 @@ export class GetAvailableSlotsInput { @IsOptional() @ApiPropertyOptional({ type: [String], - description: "Only for dynamic events - list of usernames for which slots are being fetched.", + description: + "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.", + example: "usernameList[]=bob", }) usernameList?: string[];