diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 9b7a2c4e022491..40ed0bc9069000 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -8,6 +8,7 @@ import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/a import dayjs from "@calcom/dayjs"; // TODO: Use browser locale, implement Intl in Dayjs maybe? import "@calcom/dayjs/locales"; +import { DeleteBookingDialog } from "@calcom/features/bookings/components/dialog/DeleteBookingDialog"; import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog"; import classNames from "@calcom/lib/classNames"; import { formatTime } from "@calcom/lib/date-fns"; @@ -295,6 +296,18 @@ function BookingListItem(booking: BookingItemProps) { }); } + if (isBookingInPast) { + editBookingActions.push({ + id: "delete_history", + label: t("delete"), + onClick: () => { + setIsOpenDeleteBookingDialog(true); + }, + icon: "trash" as const, + color: "destructive", + }); + } + let bookedActions: ActionType[] = [ { id: "cancel", @@ -351,6 +364,7 @@ function BookingListItem(booking: BookingItemProps) { const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); + const [isOpenDeleteBookingDialog, setIsOpenDeleteBookingDialog] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { showToast(t("location_updated"), "success"); @@ -465,6 +479,11 @@ function BookingListItem(booking: BookingItemProps) { setIsOpenDialog={setIsOpenAddGuestsDialog} bookingId={booking.id} /> + {booking.paid && booking.payment[0] && ( >; + bookingId: number; +} + +export const DeleteBookingDialog = (props: IDeleteBookingDialog) => { + const { t } = useLocale(); + const { isOpenDialog, setIsOpenDialog, bookingId } = props; + const utils = trpc.useUtils(); + + const deleteBookingMutation = trpc.viewer.bookings.deleteBooking.useMutation({ + onSuccess: async () => { + showToast(t("booking_delete_successfully"), "success"); + setIsOpenDialog(false); + utils.viewer.bookings.invalidate(); + }, + onError: (err) => { + const message = `${err.data?.code}: ${t(err.message)}`; + showToast(message || t("unable_to_delete_booking"), "error"); + }, + }); + + return ( + + { + e.preventDefault(); + deleteBookingMutation.mutate({ id: bookingId }); + }}> + {t("delete_booking_description")} + + + ); +}; diff --git a/packages/features/bookings/components/dialog/__tests__/DeleteBookingDialog.test.tsx b/packages/features/bookings/components/dialog/__tests__/DeleteBookingDialog.test.tsx new file mode 100644 index 00000000000000..c3222f2c16cb81 --- /dev/null +++ b/packages/features/bookings/components/dialog/__tests__/DeleteBookingDialog.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import * as React from "react"; +import { vi } from "vitest"; + +import { DeleteBookingDialog } from "../DeleteBookingDialog"; + +const invalidateMock = vi.fn(); + +vi.mock("@calcom/trpc", () => ({ + trpc: { + useUtils: vi.fn(() => ({ + viewer: { + bookings: { + invalidate: invalidateMock, + }, + }, + })), + viewer: { + bookings: { + deleteBooking: { + useMutation: vi.fn(({ onSuccess }: { onSuccess: () => void }) => ({ + mutate: () => { + onSuccess(); + }, + isPending: false, + })), + }, + }, + }, + }, +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); + +describe("DeleteBookingDialog", () => { + const mockProps = { + bookingId: 123, + isOpenDialog: true, + setIsOpenDialog: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the dialog when open", () => { + render(); + expect(screen.getByText("delete_booking_title")).toBeInTheDocument(); + expect(screen.getByText("delete_booking_description")).toBeInTheDocument(); + }); + + it("closes the dialog when cancel is clicked", async () => { + render(); + fireEvent.click(screen.getByText("cancel")); + expect(mockProps.setIsOpenDialog).toHaveBeenCalledWith(false); + }); + + it("deletes the booking and closes the dialog", () => { + render(); + + fireEvent.click(screen.getByText("confirm")); + expect(mockProps.setIsOpenDialog).toHaveBeenCalledWith(false); + expect(invalidateMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/__tests__/deleteBooking.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/__tests__/deleteBooking.handler.test.ts new file mode 100644 index 00000000000000..6f288b0350d877 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/__tests__/deleteBooking.handler.test.ts @@ -0,0 +1,38 @@ +import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { deleteBookingHandler } from "@calcom/trpc/server/routers/viewer/bookings/deleteBooking.handler"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +describe("Booking deletion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const ctx = { + user: { + id: 123, + name: "test", + timeZone: "timeZone", + username: "test_username", + } as NonNullable, + }; + + it("should delete a single booking successfully", async () => { + const mockBooking: any = { id: 1, userId: 123 }; + + // Set the spy to resolve with your mock data + prismaMock.booking.delete.mockResolvedValueOnce(mockBooking); + + await deleteBookingHandler({ + ctx, + input: { id: 1 }, + }); + + expect(prismaMock.booking.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.booking.delete).toHaveBeenCalledWith({ + where: { id: 1, userId: 123 }, + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 91cd36ea5414e3..b4d39bfdb65db7 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -3,6 +3,7 @@ import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZAddGuestsInputSchema } from "./addGuests.schema"; import { ZConfirmInputSchema } from "./confirm.schema"; +import { ZDeleteBookingInputSchema } from "./deleteBooking.schema"; import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; @@ -16,6 +17,7 @@ type BookingsRouterHandlerCache = { requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; editLocation?: typeof import("./editLocation.handler").editLocationHandler; addGuests?: typeof import("./addGuests.handler").addGuestsHandler; + deleteBooking?: typeof import("./deleteBooking.handler").deleteBookingHandler; confirm?: typeof import("./confirm.handler").confirmHandler; getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; find?: typeof import("./find.handler").getHandler; @@ -93,6 +95,25 @@ export const bookingsRouter = router({ input, }); }), + deleteBooking: authedProcedure.input(ZDeleteBookingInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.deleteBooking) { + // Dynamically import the deleteBooking handler + UNSTABLE_HANDLER_CACHE.deleteBooking = await import("./deleteBooking.handler").then( + (mod) => mod.deleteBookingHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.deleteBooking) { + throw new Error("Failed to load handler"); + } + + // Execute the handler with the context and input + return UNSTABLE_HANDLER_CACHE.deleteBooking({ + ctx, + input, + }); + }), confirm: authedProcedure.input(ZConfirmInputSchema).mutation(async ({ input, ctx }) => { if (!UNSTABLE_HANDLER_CACHE.confirm) { diff --git a/packages/trpc/server/routers/viewer/bookings/deleteBooking.handler.ts b/packages/trpc/server/routers/viewer/bookings/deleteBooking.handler.ts new file mode 100644 index 00000000000000..75b40287f162b8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/deleteBooking.handler.ts @@ -0,0 +1,26 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteInputSchema } from "./deleteBooking.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteBookingHandler = async ({ ctx, input }: DeleteOptions) => { + const { user } = ctx; + const { id } = input; + await prisma.booking.delete({ + where: { + userId: user.id, + id, + }, + }); + + return { + id, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/deleteBooking.schema.ts b/packages/trpc/server/routers/viewer/bookings/deleteBooking.schema.ts new file mode 100644 index 00000000000000..71f4e4543e2f98 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/deleteBooking.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteBookingInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer;