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 (
+
+ );
+};
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;