From cc2164657cfcd791429697df5e9264bbe2f9ddc1 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Wed, 27 Mar 2024 18:04:54 +0530 Subject: [PATCH] feat: platform endpoints to fetch/cancel/reschedule bookings and hooks (#14164) * init bookings endpoint * abstracting functions * e2e tests for bookings * hooks for bookings endpoint * bookings respository fixtures * typings for booking input * fixup * abstract booking info code and use it as handler * import handlers for bookings endpoint * add cancel booking input * add handleCancelBooking handler in platform libraries * cancel booking endpoint * abstract call into its own separate fn * cancel booking hook * e2e test for cancel booking endpoint * fix import * export getBookings function * move getAllUserBookings into lib * add bookings folder to package exports * use getAllUserBookings from lib * fix import path * fix: hooks, endpoint and example for cancel / reschedule / list / booking success page * fix: unit test mock classNames import from lib * fix: unit test mock classNames import from lib --------- Co-authored-by: Morgan Vernay Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- .../bookings.controller.e2e-spec.ts | 236 ++++++++++++++++++ .../controllers/bookings.controller.ts | 100 +++++++- .../repository/bookings.repository.fixture.ts | 26 ++ ...ookings-single-view.getServerSideProps.tsx | 58 +---- apps/web/tsconfig.json | 4 +- packages/app-store/test-setup.ts | 5 + packages/features/bookings/Booker/store.ts | 5 + .../features/bookings/lib/getBookingInfo.ts | 17 ++ .../features/bookings/lib/getUserBooking.ts | 60 +++++ packages/lib/bookings/getAllUserBookings.ts | 104 ++++++++ packages/lib/index.ts | 1 + .../atoms/booker/BookerPlatformWrapper.tsx | 9 + .../platform/atoms/hooks/useCancelBooking.ts | 48 ++++ .../platform/atoms/hooks/useGetBooking.ts | 33 +++ .../atoms/hooks/useGetBookingForReschedule.ts | 55 ++++ .../platform/atoms/hooks/useGetBookings.ts | 34 +++ packages/platform/atoms/index.ts | 3 + packages/platform/constants/api.ts | 1 + .../base/src/components/Navbar/index.tsx | 5 +- .../examples/base/src/pages/[bookingUid].tsx | 62 +++++ .../examples/base/src/pages/booking.tsx | 24 +- .../examples/base/src/pages/bookings.tsx | 53 ++++ packages/platform/libraries/index.ts | 9 + packages/platform/types/bookings.ts | 82 ++++++ packages/platform/types/index.ts | 1 + .../routers/viewer/bookings/get.handler.ts | 77 +----- packages/ui/components/test-setup.ts | 5 + 27 files changed, 986 insertions(+), 131 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts create mode 100644 apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts create mode 100644 packages/features/bookings/lib/getBookingInfo.ts create mode 100644 packages/features/bookings/lib/getUserBooking.ts create mode 100644 packages/lib/bookings/getAllUserBookings.ts create mode 100644 packages/platform/atoms/hooks/useCancelBooking.ts create mode 100644 packages/platform/atoms/hooks/useGetBooking.ts create mode 100644 packages/platform/atoms/hooks/useGetBookingForReschedule.ts create mode 100644 packages/platform/atoms/hooks/useGetBookings.ts create mode 100644 packages/platform/examples/base/src/pages/[bookingUid].tsx create mode 100644 packages/platform/examples/base/src/pages/bookings.tsx create mode 100644 packages/platform/types/bookings.ts diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts new file mode 100644 index 0000000000..b0372ebb86 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts @@ -0,0 +1,236 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { + getAllUserBookings, + handleNewBooking, + getBookingInfo, + handleNewRecurringBooking, + handleInstantMeeting, +} from "@calcom/platform-libraries"; +import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types"; + +describe("Bookings Endpoints", () => { + describe("User Authenticated", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + + const userEmail = "bookings-controller-e2e@api.com"; + let user: User; + + let createdBooking: Awaited>; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule], + providers: [SchedulesRepository, SchedulesService], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a booking", async () => { + const bookingStart = "2023-05-25T09:30:00.000Z"; + const bookingEnd = "2023-05-25T10:30:00.000Z"; + const bookingEventTypeId = 7; + const bookingTimeZone = "Europe/Londom"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = {}; + + const body = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + }; + + return request(app.getHttpServer()) + .post("/api/v2/ee/bookings") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toBeDefined(); + expect(responseBody.data.user.email).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(bookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(bookingMetadata); + + createdBooking = responseBody.data; + }); + }); + + it("should get bookings", async () => { + return request(app.getHttpServer()) + .get("/api/v2/ee/bookings") + .then((response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + const fetchedBooking = responseBody.data.bookings[0]; + + expect(responseBody.data.bookings.length).toEqual(1); + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(fetchedBooking).toBeDefined(); + + expect(fetchedBooking.id).toEqual(createdBooking.id); + expect(fetchedBooking.uid).toEqual(createdBooking.uid); + expect(fetchedBooking.startTime).toEqual(createdBooking.startTime); + expect(fetchedBooking.endTime).toEqual(createdBooking.endTime); + expect(fetchedBooking.user?.email).toEqual(createdBooking.user.email); + }); + }); + + it("should get booking", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/ee/bookings/${createdBooking.uid}`) + .then((response) => { + const responseBody: ApiSuccessResponse>> = response.body; + const { bookingInfo } = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(bookingInfo?.id).toBeDefined(); + expect(bookingInfo?.uid).toBeDefined(); + expect(bookingInfo?.id).toEqual(createdBooking.id); + expect(bookingInfo?.uid).toEqual(createdBooking.uid); + expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(bookingInfo?.startTime).toEqual(createdBooking.startTime); + }); + }); + + it("should create a recurring booking", async () => { + const bookingStart = "2023-05-25T09:30:00.000Z"; + const bookingEnd = "2023-05-25T10:30:00.000Z"; + const bookingEventTypeId = 7; + const bookingTimeZone = "Europe/Londom"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingRecurringCount = 5; + const currentBookingRecurringIndex = 0; + + const body = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: {}, + hashedLink: bookingHashedLink, + recurringCount: bookingRecurringCount, + currentRecurringIndex: currentBookingRecurringIndex, + }; + + return request(app.getHttpServer()) + .post("/api/v2/ee/bookings/reccuring") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiResponse>> = + response.body; + + expect(responseBody.status).toEqual("recurring"); + }); + }); + + it("should create an instant booking", async () => { + const bookingStart = "2023-05-25T09:30:00.000Z"; + const bookingEnd = "2023-05-25T10:30:00.000Z"; + const bookingEventTypeId = 7; + const bookingTimeZone = "Europe/Londom"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + + const body = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: {}, + hashedLink: bookingHashedLink, + }; + + return request(app.getHttpServer()) + .post("/api/v2/ee/bookings/instant") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiResponse>> = response.body; + + expect(responseBody.status).toEqual("instant"); + }); + }); + + it("should cancel a booking", async () => { + const bookingId = createdBooking.id; + + const body = { + allRemainingBookings: false, + cancellationReason: "Was fighting some unforseen rescheduling demons", + }; + + return request(app.getHttpServer()) + .post(`/api/v2/ee/bookings/${bookingId}/cancel`) + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> = + response.body; + + expect(bookingId).toBeDefined(); + expect(responseBody.status).toEqual(SUCCESS_STATUS); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 1377970f95..57d87e983c 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -1,8 +1,11 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { Controller, Post, @@ -11,12 +14,23 @@ import { InternalServerErrorException, Body, HttpException, + Param, + Get, + Query, + NotFoundException, UseGuards, } from "@nestjs/common"; +import { User } from "@prisma/client"; import { Request } from "express"; import { NextApiRequest } from "next/types"; import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + getAllUserBookings, + getBookingInfo, + handleCancelBooking, + getBookingForReschedule, +} from "@calcom/platform-libraries"; import { handleNewBooking, BookingResponse, @@ -24,7 +38,9 @@ import { handleNewRecurringBooking, handleInstantMeeting, } from "@calcom/platform-libraries"; +import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; @Controller({ path: "ee/bookings", @@ -34,7 +50,65 @@ import { ApiResponse } from "@calcom/platform-types"; export class BookingsController { private readonly logger = new Logger("ee bookings controller"); - constructor(private readonly oAuthFlowService: OAuthFlowService) {} + constructor( + private readonly oAuthFlowService: OAuthFlowService, + private readonly prismaReadService: PrismaReadService + ) {} + + // note(Rajiv): currently this endpoint is atoms only + @Get("/") + @UseGuards(AccessTokenGuard) + async getBookings( + @GetUser() user: User, + @Query() queryParams: GetBookingsInput + ): Promise> { + const { filters, cursor, limit } = queryParams; + const bookings = await getAllUserBookings({ + bookingListingByStatus: filters.status, + skip: cursor ?? 0, + take: limit ?? 10, + filters, + ctx: { + user: { email: user.email, id: user.id }, + prisma: this.prismaReadService.prisma as unknown as PrismaClient, + }, + }); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } + + // note(Rajiv): currently this endpoint is atoms only + @Get("/:bookingUid") + async getBooking(@Param("bookingUid") bookingUid: string): Promise> { + const { bookingInfo } = await getBookingInfo(bookingUid); + + if (!bookingInfo) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: bookingInfo, + }; + } + + // note(Rajiv): currently this endpoint is atoms only + @Get("/:bookingUid/reschedule") + async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise> { + const booking = await getBookingForReschedule(bookingUid); + + if (!booking) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } @Post("/") @Permissions([BOOKING_WRITE]) @@ -56,6 +130,30 @@ export class BookingsController { throw new InternalServerErrorException("Could not create booking."); } + @Post("/:bookingId/cancel") + @Permissions([BOOKING_WRITE]) + async cancelBooking( + @Req() req: Request & { userId?: number }, + @Param("bookingId") bookingId: string, + @Body() body: CancelBookingInput + ): Promise { + if (bookingId) { + req.userId = await this.getOwnerId(req); + req.body = { ...body, id: parseInt(bookingId) }; + try { + await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number }); + return { + status: SUCCESS_STATUS, + }; + } catch (err) { + handleBookingErrors(err); + } + } else { + throw new NotFoundException("Booking ID is required."); + } + throw new InternalServerErrorException("Could not cancel booking."); + } + @Post("/reccuring") @Permissions([BOOKING_WRITE]) async createReccuringBooking( diff --git a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts new file mode 100644 index 0000000000..fc27078bf2 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Booking, User } from "@prisma/client"; + +export class BookingsRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(bookingId: Booking["id"]) { + return this.primaReadClient.booking.findFirst({ where: { id: bookingId } }); + } + + async deleteById(bookingId: Booking["id"]) { + return this.prismaWriteClient.booking.delete({ where: { id: bookingId } }); + } + + async deleteAllBookings(userId: User["id"], userEmail: User["email"]) { + return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } }); + } +} diff --git a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx index 8a303b3394..eb3430591a 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx @@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; +import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import { parseRecurringEvent } from "@calcom/lib"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; @@ -62,58 +62,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const maybeBookingUidFromSeat = await maybeGetBookingUidFromSeat(prisma, uid); if (maybeBookingUidFromSeat.uid) uid = maybeBookingUidFromSeat.uid; if (maybeBookingUidFromSeat.seatReferenceUid) seatReferenceUid = maybeBookingUidFromSeat.seatReferenceUid; - const bookingInfoRaw = await prisma.booking.findFirst({ - where: { - uid: uid, - }, - select: { - title: true, - id: true, - uid: true, - description: true, - customInputs: true, - smsReminderNumber: true, - recurringEventId: true, - startTime: true, - endTime: true, - location: true, - status: true, - metadata: true, - cancellationReason: true, - responses: true, - rejectionReason: true, - userPrimaryEmail: true, - user: { - select: { - id: true, - name: true, - email: true, - username: true, - timeZone: true, - }, - }, - attendees: { - select: { - name: true, - email: true, - timeZone: true, - }, - }, - eventTypeId: true, - eventType: { - select: { - eventName: true, - slug: true, - timeZone: true, - }, - }, - seatsReferences: { - select: { - referenceUid: true, - }, - }, - }, - }); + + const { bookingInfoRaw, bookingInfo } = await getBookingInfo(uid); + if (!bookingInfoRaw) { return { notFound: true, @@ -133,7 +84,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { requiresLoginToUpdate = true; } - const bookingInfo = getBookingWithResponses(bookingInfoRaw); // @NOTE: had to do this because Server side cant return [Object objects] // probably fixable with json.stringify -> json.parse bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8a173ecdc7..44cf0ae191 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,7 +26,9 @@ "../../packages/types/next-auth.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "../../packages/features/bookings/lib/getBookingInfo.ts", + "../../packages/features/bookings/lib/getUserBooking.ts" ], "exclude": ["node_modules"] } diff --git a/packages/app-store/test-setup.ts b/packages/app-store/test-setup.ts index 2070561ba8..4ab01f0507 100644 --- a/packages/app-store/test-setup.ts +++ b/packages/app-store/test-setup.ts @@ -26,6 +26,11 @@ vi.mock("@calcom/atoms/monorepo", () => ({ return false; }, })); +vi.mock("@calcom/lib", () => ({ + classNames: (...args: string[]) => { + return args.filter(Boolean).join(" "); + }, +})); vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({})); vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({})); diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 1c7954d74b..96472c28eb 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -118,6 +118,8 @@ export type BookerStore = { rescheduleUid: string | null; bookingUid: string | null; bookingData: GetBookingType | null; + setBookingData: (bookingData: GetBookingType | null | undefined) => void; + /** * Method called by booker component to set initial data. */ @@ -317,6 +319,9 @@ export const useBookerStore = create((set, get) => ({ set({ selectedDuration }); updateQueryParam("duration", selectedDuration ?? ""); }, + setBookingData: (bookingData: GetBookingType | null | undefined) => { + set({ bookingData: bookingData ?? null }); + }, recurringEventCount: null, setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }), occurenceCount: null, diff --git a/packages/features/bookings/lib/getBookingInfo.ts b/packages/features/bookings/lib/getBookingInfo.ts new file mode 100644 index 0000000000..38ed23f92b --- /dev/null +++ b/packages/features/bookings/lib/getBookingInfo.ts @@ -0,0 +1,17 @@ +import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; + +import getUserBooking from "./getUserBooking"; + +const getBookingInfo = async (uid: string) => { + const bookingInfoRaw = await getUserBooking(uid); + + if (!bookingInfoRaw) { + return { bookingInfoRaw: undefined, bookingInfo: undefined }; + } + + const bookingInfo = getBookingWithResponses(bookingInfoRaw); + + return { bookingInfoRaw, bookingInfo }; +}; + +export default getBookingInfo; diff --git a/packages/features/bookings/lib/getUserBooking.ts b/packages/features/bookings/lib/getUserBooking.ts new file mode 100644 index 0000000000..7aaab4fc40 --- /dev/null +++ b/packages/features/bookings/lib/getUserBooking.ts @@ -0,0 +1,60 @@ +import prisma from "@calcom/prisma"; + +const getUserBooking = async (uid: string) => { + const bookingInfo = await prisma.booking.findFirst({ + where: { + uid: uid, + }, + select: { + title: true, + id: true, + uid: true, + description: true, + customInputs: true, + smsReminderNumber: true, + recurringEventId: true, + startTime: true, + endTime: true, + location: true, + status: true, + metadata: true, + cancellationReason: true, + responses: true, + rejectionReason: true, + userPrimaryEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + username: true, + timeZone: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + eventTypeId: true, + eventType: { + select: { + eventName: true, + slug: true, + timeZone: true, + }, + }, + seatsReferences: { + select: { + referenceUid: true, + }, + }, + }, + }); + + return bookingInfo; +}; + +export default getUserBooking; diff --git a/packages/lib/bookings/getAllUserBookings.ts b/packages/lib/bookings/getAllUserBookings.ts new file mode 100644 index 0000000000..4aa1c70cfa --- /dev/null +++ b/packages/lib/bookings/getAllUserBookings.ts @@ -0,0 +1,104 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { getBookings } from "@calcom/trpc/server/routers/viewer/bookings/get.handler"; + +type InputByStatus = "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed"; +type GetOptions = { + ctx: { + user: { id: number; email: string }; + prisma: PrismaClient; + }; + bookingListingByStatus: InputByStatus; + take: number; + skip: number; + filters: { + status: InputByStatus; + teamIds?: number[] | undefined; + userIds?: number[] | undefined; + eventTypeIds?: number[] | undefined; + }; +}; + +const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip }: GetOptions) => { + const { prisma, user } = ctx; + + const bookingListingFilters: Record = { + upcoming: { + endTime: { gte: new Date() }, + // These changes are needed to not show confirmed recurring events, + // as rescheduling or cancel for recurring event bookings should be + // handled separately for each occurrence + OR: [ + { + recurringEventId: { not: null }, + status: { equals: BookingStatus.ACCEPTED }, + }, + { + recurringEventId: { equals: null }, + status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] }, + }, + ], + }, + recurring: { + endTime: { gte: new Date() }, + AND: [ + { NOT: { recurringEventId: { equals: null } } }, + { status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } }, + ], + }, + past: { + endTime: { lte: new Date() }, + AND: [ + { NOT: { status: { equals: BookingStatus.CANCELLED } } }, + { NOT: { status: { equals: BookingStatus.REJECTED } } }, + ], + }, + cancelled: { + OR: [{ status: { equals: BookingStatus.CANCELLED } }, { status: { equals: BookingStatus.REJECTED } }], + }, + unconfirmed: { + endTime: { gte: new Date() }, + status: { equals: BookingStatus.PENDING }, + }, + }; + const bookingListingOrderby: Record< + typeof bookingListingByStatus, + Prisma.BookingOrderByWithAggregationInput + > = { + upcoming: { startTime: "asc" }, + recurring: { startTime: "asc" }, + past: { startTime: "desc" }, + cancelled: { startTime: "desc" }, + unconfirmed: { startTime: "asc" }, + }; + + const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; + const orderBy = bookingListingOrderby[bookingListingByStatus]; + + const { bookings, recurringInfo } = await getBookings({ + user, + prisma, + passedBookingsStatusFilter, + filters: filters, + orderBy, + take, + skip, + }); + + const bookingsFetched = bookings.length; + let nextCursor: typeof skip | null = skip; + if (bookingsFetched > take) { + nextCursor += bookingsFetched; + } else { + nextCursor = null; + } + + return { + bookings, + recurringInfo, + nextCursor, + }; +}; + +export default getAllUserBookings; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 8746790c23..fc51c1cbe0 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -7,3 +7,4 @@ export * from "./validateIntervalLimitOrder"; export * from "./schedules"; export * from "./event-types/getEventTypesByViewer"; export * from "./event-types/getEventTypesPublic"; +export * from "./bookings/getAllUserBookings"; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 5244020632..1bd3cdb8e9 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -27,6 +27,7 @@ import { useConnectedCalendars } from "../hooks/useConnectedCalendars"; import { useCreateBooking } from "../hooks/useCreateBooking"; import { useCreateInstantBooking } from "../hooks/useCreateInstantBooking"; import { useCreateRecurringBooking } from "../hooks/useCreateRecurringBooking"; +import { useGetBookingForReschedule } from "../hooks/useGetBookingForReschedule"; import { useHandleBookEvent } from "../hooks/useHandleBookEvent"; import { useMe } from "../hooks/useMe"; import { usePublicEvent } from "../hooks/usePublicEvent"; @@ -54,7 +55,15 @@ type BookerPlatformWrapperAtomProps = Omit & { export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => { const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const setSelectedDate = useBookerStore((state) => state.setSelectedDate); + const setBookingData = useBookerStore((state) => state.setBookingData); + const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); + const { data: booking } = useGetBookingForReschedule({ + uid: props.rescheduleUid ?? props.bookingUid ?? "", + onSuccess: (data) => { + setBookingData(data); + }, + }); useEffect(() => { // reset booker whenever it's unmounted diff --git a/packages/platform/atoms/hooks/useCancelBooking.ts b/packages/platform/atoms/hooks/useCancelBooking.ts new file mode 100644 index 0000000000..3960b8fe40 --- /dev/null +++ b/packages/platform/atoms/hooks/useCancelBooking.ts @@ -0,0 +1,48 @@ +import { useMutation } from "@tanstack/react-query"; +import type { z } from "zod"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types"; +import type { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; + +import http from "../lib/http"; + +interface IUseCancelBooking { + onSuccess?: () => void; + onError?: (err: ApiErrorResponse | Error) => void; +} + +type inputParams = z.infer; + +export const useCancelBooking = ( + { onSuccess, onError }: IUseCancelBooking = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const cancelBooking = useMutation({ + mutationFn: (data) => { + return http.post(`/ee/bookings/${data.id}/cancel`, data).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return res.data; + } + throw new Error(res.data.error.message); + }); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(); + } else { + onError?.(data); + } + }, + onError: (err) => { + onError?.(err); + }, + }); + return cancelBooking; +}; diff --git a/packages/platform/atoms/hooks/useGetBooking.ts b/packages/platform/atoms/hooks/useGetBooking.ts new file mode 100644 index 0000000000..8cefb1b77e --- /dev/null +++ b/packages/platform/atoms/hooks/useGetBooking.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; + +import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { getBookingInfo } from "@calcom/platform-libraries"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import http from "../lib/http"; + +export const QUERY_KEY = "user-booking"; + +export const useGetBooking = (uid = "") => { + const endpoint = new URL(BASE_URL); + + endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}/${uid}`; + + const bookingQuery = useQuery({ + queryKey: [QUERY_KEY, uid], + queryFn: () => { + return http + .get>["bookingInfo"]>>(endpoint.toString()) + .then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse>["bookingInfo"]>) + .data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: !!uid, + }); + + return bookingQuery; +}; diff --git a/packages/platform/atoms/hooks/useGetBookingForReschedule.ts b/packages/platform/atoms/hooks/useGetBookingForReschedule.ts new file mode 100644 index 0000000000..4a93fbbda0 --- /dev/null +++ b/packages/platform/atoms/hooks/useGetBookingForReschedule.ts @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; + +import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { getBookingForReschedule } from "@calcom/platform-libraries"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import http from "../lib/http"; + +export const QUERY_KEY = "user-booking"; + +interface IUseGetBookingForReschedule { + onSuccess?: (res: ApiSuccessResponse>>["data"]) => void; + onError?: (err: Error) => void; + uid?: string; +} +export const useGetBookingForReschedule = ( + props: IUseGetBookingForReschedule = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + uid: "", + } +) => { + const endpoint = new URL(BASE_URL); + + endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}/${props.uid}/reschedule`; + + const bookingQuery = useQuery({ + queryKey: [QUERY_KEY, props.uid], + queryFn: () => { + return http + .get>>>(endpoint.toString()) + .then((res) => { + if (res.data.status === SUCCESS_STATUS) { + props.onSuccess?.( + (res.data as ApiSuccessResponse>>).data + ); + return (res.data as ApiSuccessResponse>>).data; + } + const error = new Error(res.data.error.message); + props.onError?.(error); + throw error; + }) + .catch((err) => { + props.onError?.(err); + }); + }, + enabled: !!props?.uid, + }); + + return bookingQuery; +}; diff --git a/packages/platform/atoms/hooks/useGetBookings.ts b/packages/platform/atoms/hooks/useGetBookings.ts new file mode 100644 index 0000000000..dfff9c3d93 --- /dev/null +++ b/packages/platform/atoms/hooks/useGetBookings.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; + +import { BASE_URL, SUCCESS_STATUS, API_VERSION, V2_ENDPOINTS } from "@calcom/platform-constants"; +import type { getAllUserBookings } from "@calcom/platform-libraries"; +import type { ApiResponse } from "@calcom/platform-types"; +import type { GetBookingsInput } from "@calcom/platform-types/bookings"; + +import http from "../lib/http"; + +export const QUERY_KEY = "user-bookings"; + +export const useGetBookings = (input: GetBookingsInput) => { + const endpoint = new URL(BASE_URL); + + endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}`; + + const bookingsQuery = useQuery({ + queryKey: [QUERY_KEY, input?.limit ?? 50, input?.cursor ?? 0, input?.filters?.status ?? "upcoming"], + queryFn: () => { + return http + .get>>>(endpoint.toString(), { + params: input, + }) + .then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return res.data.data; + } + throw new Error(res.data.error.message); + }); + }, + }); + + return bookingsQuery; +}; diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 5d732c90e1..6a395e1132 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -6,3 +6,6 @@ export { useIsPlatform } from "./hooks/useIsPlatform"; export { useAtomsContext } from "./hooks/useAtomsContext"; export { useConnectedCalendars } from "./hooks/useConnectedCalendars"; export { useEventTypesPublic } from "./hooks/event-types/useEventTypesPublic"; +export { useCancelBooking } from "./hooks/useCancelBooking"; +export { useGetBooking } from "./hooks/useGetBooking"; +export { useGetBookings } from "./hooks/useGetBookings"; diff --git a/packages/platform/constants/api.ts b/packages/platform/constants/api.ts index 3cfb5409b0..1f0b0c7744 100644 --- a/packages/platform/constants/api.ts +++ b/packages/platform/constants/api.ts @@ -4,6 +4,7 @@ export const V2_ENDPOINTS = { me: "me", availability: "schedules", eventTypes: "event-types", + bookings: "ee/bookings", }; export const SUCCESS_STATUS = "success"; diff --git a/packages/platform/examples/base/src/components/Navbar/index.tsx b/packages/platform/examples/base/src/components/Navbar/index.tsx index 7a23d24d83..4ef7ea3086 100644 --- a/packages/platform/examples/base/src/components/Navbar/index.tsx +++ b/packages/platform/examples/base/src/components/Navbar/index.tsx @@ -23,7 +23,10 @@ export function Navbar({ username }: { username?: string }) { Availability
  • - Bookings + Book Me +
  • +
  • + My Bookings
  • diff --git a/packages/platform/examples/base/src/pages/[bookingUid].tsx b/packages/platform/examples/base/src/pages/[bookingUid].tsx new file mode 100644 index 0000000000..0c4cc8250d --- /dev/null +++ b/packages/platform/examples/base/src/pages/[bookingUid].tsx @@ -0,0 +1,62 @@ +import { Navbar } from "@/components/Navbar"; +import { Inter } from "next/font/google"; +import { useRouter } from "next/router"; + +import { useGetBooking, useCancelBooking } from "@calcom/atoms"; + +const inter = Inter({ subsets: ["latin"] }); + +export default function Bookings(props: { calUsername: string; calEmail: string }) { + const router = useRouter(); + const { isLoading, data: booking, refetch } = useGetBooking((router.query.bookingUid as string) ?? ""); + const { mutate: cancelBooking } = useCancelBooking({ + onSuccess: () => { + refetch(); + }, + }); + return ( +
    + + {isLoading &&

    Loading...

    } + {!isLoading && booking && ( +
    +
    +
    +

    {booking.title}

    +
    +
    +
    +

    {booking.description}

    +

    {booking.startTime}

    +

    {booking.endTime}

    +

    {booking.status}

    +
    +
    + + +
    +
    + )} +
    + ); +} diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 21a0427c09..e0606fdb01 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -1,5 +1,6 @@ import { Navbar } from "@/components/Navbar"; import { Inter } from "next/font/google"; +import { useRouter } from "next/router"; import { useState } from "react"; import { Booker, useEventTypesPublic } from "@calcom/atoms"; @@ -9,9 +10,10 @@ const inter = Inter({ subsets: ["latin"] }); export default function Bookings(props: { calUsername: string; calEmail: string }) { const [bookingTitle, setBookingTitle] = useState(null); const [eventTypeSlug, setEventTypeSlug] = useState(null); - + const router = useRouter(); const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypesPublic(props.calUsername); - console.log(isLoadingEvents, eventTypes); + const rescheduleUid = (router.query.rescheduleUid as string) ?? ""; + const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? ""; return (
    @@ -21,7 +23,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string {isLoadingEvents && !eventTypeSlug &&

    Loading...

    } - {!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && ( + {!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && !rescheduleUid && (
    {eventTypes?.map((event: { id: number; slug: string; title: string }) => (