diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 556f4f31ba8395..8737f6095e0d26 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -26,6 +26,7 @@ import type { ActionType } from "@calcom/ui"; import { Badge, Button, + ConfirmationDialogContent, Dialog, DialogClose, DialogContent, @@ -119,6 +120,7 @@ function BookingListItem(booking: BookingItemProps) { const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false); const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const cardCharged = booking?.payment[0]?.success; const mutation = trpc.viewer.bookings.confirm.useMutation({ onSuccess: (data) => { @@ -291,6 +293,17 @@ function BookingListItem(booking: BookingItemProps) { }); } + if (isBookingInPast) { + editBookingActions.push({ + id: "delete", + label: t("delete_booking"), + onClick: () => { + setDeleteDialogOpen(true); + }, + icon: "trash", + }); + } + let bookedActions: ActionType[] = [ { id: "cancel", @@ -433,6 +446,22 @@ function BookingListItem(booking: BookingItemProps) { }; }); + const deleteMutation = trpc.viewer.bookings.delete.useMutation({ + onSuccess: () => { + showToast(t("booking_deleted_successfully"), "success"); + setDeleteDialogOpen(false); + // Invalidate the bookings query to refresh the list + utils.viewer.bookings.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const deleteBookingHandler = (id: number) => { + deleteMutation.mutate({ id }); + }; + return ( <> + + + { + e.preventDefault(); + deleteBookingHandler(booking.id); + }}> +

{t("delete_booking_description")}

+
+
+
{ + const { t } = useLocale(); + const utils = trpc.useContext(); + + const deletePastBookingsMutation = trpc.viewer.bookings.deletePastBookings.useMutation({ + onSuccess: (data) => { + showToast(data.message, "success"); + utils.viewer.bookings.get.invalidate(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const handleDeletePastBookings = () => { + deletePastBookingsMutation.mutate({ bookingIds }); + setIsDialogOpen(false); + }; + + return ( + <> + + + + + +

+ {t("confirm_delete_past_bookings")} +
+ + {bookingsCount} {bookingsCount === 1 ? "booking" : "bookings"} will be deleted. + +

+ + + + +
+
+ + ); +}; diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 7d1cb8102e3487..0f22f602145dc6 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -26,6 +26,7 @@ import { Alert, EmptyScreen, HorizontalTabs } from "@calcom/ui"; import useMeQuery from "@lib/hooks/useMeQuery"; import BookingListItem from "@components/booking/BookingListItem"; +import { DeletePastBookingsSection } from "@components/booking/DeletePastBookingsSection"; import SkeletonLoader from "@components/booking/SkeletonLoader"; import type { validStatuses } from "~/bookings/lib/validStatuses"; @@ -96,7 +97,6 @@ type RowData = function BookingsContent({ status }: BookingsProps) { const { data: filterQuery } = useFilterQuery(); - const { t } = useLocale(); const user = useMeQuery().data; const [isFiltersVisible, setIsFiltersVisible] = useState(false); @@ -155,7 +155,7 @@ function BookingsContent({ status }: BookingsProps) { }, }), ]; - }, [user, status]); + }, [user, status, t]); const isEmpty = useMemo(() => !query.data?.pages[0]?.bookings.length, [query.data]); @@ -242,9 +242,19 @@ function BookingsContent({ status }: BookingsProps) { return (
-
+
- +
+ {status === "past" && flatData.length > 0 && ( + (item.type === "data" ? item.booking.id : null)) + .filter((id): id is number => id !== null)} + /> + )} + +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a78ae00dea6e73..511f1098d8e85d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2956,5 +2956,13 @@ "desc": "Desc", "verify_email": "Verify email", "verify_email_change": "Verify email change", - "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" + "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑", + "delete_booking": "Delete", + "delete_booking_dialog_title": "Delete booking?", + "confirm_delete_booking": "Confirm", + "delete_booking_description":"This action will delete booking from your history.", + "booking_deleted_successfully": "Your booking was deleted.", + "delete_past_bookings": "Delete Past Bookings", + "confirm_delete_past_bookings": "Are you sure you want to delete all past bookings? This action cannot be undone.", + "past_bookings_deleted_successfully": "Past bookings deleted successfully" } diff --git a/apps/web/test/lib/deleteBooking.test.ts b/apps/web/test/lib/deleteBooking.test.ts new file mode 100644 index 00000000000000..5138d5373c0763 --- /dev/null +++ b/apps/web/test/lib/deleteBooking.test.ts @@ -0,0 +1,212 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import type { Booking } from "@calcom/prisma/client"; +import { BookingStatus } from "@calcom/prisma/client"; +import { BookerLayouts } from "@calcom/prisma/zod-utils"; +import { deleteHandler } from "@calcom/trpc/server/routers/viewer/bookings/delete.handler"; +import { deletePastBookingsHandler } from "@calcom/trpc/server/routers/viewer/bookings/deletePastBookings.handler"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +describe("Booking deletion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should delete a single booking successfully", async () => { + const mockBooking = { + id: 1, + status: BookingStatus.ACCEPTED, + userId: 123, + uid: "test-uid", + } as Booking; + + prismaMock.booking.findFirst.mockResolvedValue(mockBooking); + prismaMock.booking.delete.mockResolvedValue(mockBooking); + + const mockCtx = { + user: { + id: 123, + email: "test@example.com", + username: "testuser", + name: "Test User", + avatar: "avatar.png", + organization: { + id: null, + isOrgAdmin: false, + metadata: null, + requestedSlug: null, + }, + organizationId: null, + locale: "en", + defaultBookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + }, + timeZone: "UTC", + weekStart: "Monday", + startTime: 0, + endTime: 1440, + bufferTime: 0, + destinationCalendar: null, + } as NonNullable, + }; + + await deleteHandler({ + ctx: mockCtx, + input: { id: 1 }, + }); + + expect(prismaMock.booking.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.booking.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); + + it("should delete multiple past bookings correctly", async () => { + const mockBookings = [ + { id: 1, userId: 123 }, + { id: 2, userId: 123 }, + ]; + + prismaMock.booking.findMany.mockResolvedValue(mockBookings); + prismaMock.booking.deleteMany.mockResolvedValue({ count: 2 }); + + const mockCtx = { + user: { + id: 123, + email: "test@example.com", + username: "testuser", + name: "Test User", + avatar: "avatar.png", + organization: { + id: null, + isOrgAdmin: false, + metadata: null, + requestedSlug: null, + }, + organizationId: null, + locale: "en", + defaultBookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + }, + timeZone: "UTC", + weekStart: "Monday", + startTime: 0, + endTime: 1440, + bufferTime: 0, + destinationCalendar: null, + } as NonNullable, + }; + + await deletePastBookingsHandler({ + ctx: mockCtx, + input: { bookingIds: [1, 2] }, + }); + + expect(prismaMock.booking.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.booking.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [{ userId: 123 }, { attendees: { some: { email: "test@example.com" } } }], + endTime: { lt: expect.any(Date) }, + id: { in: [1, 2] }, + status: { notIn: ["CANCELLED", "REJECTED"] }, + }, + }); + }); + + it("should prevent deletion of unauthorized bookings", async () => { + const mockBooking = { + id: 1, + userId: 456, + }; + + prismaMock.booking.findFirst.mockResolvedValue(mockBooking); + + const mockCtx = { + user: { + id: 123, + email: "test@example.com", + username: "testuser", + name: "Test User", + avatar: "avatar.png", + organization: { + id: null, + isOrgAdmin: false, + metadata: null, + requestedSlug: null, + }, + organizationId: null, + locale: "en", + defaultBookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + }, + timeZone: "UTC", + weekStart: "Monday", + startTime: 0, + endTime: 1440, + bufferTime: 0, + destinationCalendar: null, + } as NonNullable, + }; + + await expect( + deleteHandler({ + ctx: mockCtx, + input: { id: 1 }, + }) + ).rejects.toThrow(/unauthorized/i); + + expect(prismaMock.booking.delete).not.toHaveBeenCalled(); + }); + + it("should prevent deletion of unauthorized multiple bookings", async () => { + const mockBooking = [ + { id: 1, userId: 456 }, + { id: 2, userId: 456 }, + ]; + + prismaMock.booking.findMany.mockResolvedValue(mockBooking); + prismaMock.booking.deleteMany.mockResolvedValue({ count: 0 }); + + const mockCtx = { + user: { + id: 123, + email: "test@example.com", + username: "testuser", + name: "Test User", + avatar: "avatar.png", + organization: { + id: null, + isOrgAdmin: false, + metadata: null, + requestedSlug: null, + }, + organizationId: null, + locale: "en", + defaultBookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + }, + timeZone: "UTC", + weekStart: "Monday", + startTime: 0, + endTime: 1440, + bufferTime: 0, + destinationCalendar: null, + } as NonNullable, + }; + + await expect( + deletePastBookingsHandler({ + ctx: mockCtx, + input: { bookingIds: [1, 2] }, + }) + ).rejects.toThrow(/unauthorized/i); + + expect(prismaMock.booking.deleteMany).not.toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 91cd36ea5414e3..41ff842832ac77 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -3,6 +3,8 @@ import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZAddGuestsInputSchema } from "./addGuests.schema"; import { ZConfirmInputSchema } from "./confirm.schema"; +import { ZDeleteInputSchema } from "./delete.schema"; +import { ZDeletePastBookingsSchema } from "./deletePastBookings.schema"; import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; @@ -20,6 +22,8 @@ type BookingsRouterHandlerCache = { getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; find?: typeof import("./find.handler").getHandler; getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler; + delete?: typeof import("./delete.handler").deleteHandler; + deletePastBookings?: typeof import("./deletePastBookings.handler").deletePastBookingsHandler; }; const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; @@ -165,4 +169,36 @@ export const bookingsRouter = router({ input, }); }), + + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.delete) { + UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); + } + + if (!UNSTABLE_HANDLER_CACHE.delete) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.delete({ + ctx, + input, + }); + }), + + deletePastBookings: authedProcedure.input(ZDeletePastBookingsSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.deletePastBookings) { + UNSTABLE_HANDLER_CACHE.deletePastBookings = await import("./deletePastBookings.handler").then( + (mod) => mod.deletePastBookingsHandler + ); + } + + if (!UNSTABLE_HANDLER_CACHE.deletePastBookings) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.deletePastBookings({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/delete.handler.ts b/packages/trpc/server/routers/viewer/bookings/delete.handler.ts new file mode 100644 index 00000000000000..698ca9f44843de --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/delete.handler.ts @@ -0,0 +1,58 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeleteInputSchema } from "./delete.schema"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; + input: TDeleteInputSchema; +}; + +export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { + const { id } = input; + const { user } = ctx; + + const booking = await prisma.booking.findFirst({ + where: { + id: id, + OR: [ + { + userId: user.id, + }, + { + attendees: { + some: { + email: user.email, + }, + }, + }, + ], + }, + include: { + attendees: true, + references: true, + payment: true, + workflowReminders: true, + }, + }); + + if (!booking) { + throw new Error("Booking not found"); + } + + if (booking.userId !== user.id) { + throw new Error("Unauthorized: You don't have permission to delete this booking"); + } + + await prisma.booking.delete({ + where: { + id: booking.id, + }, + }); + + return { + message: "Booking deleted successfully", + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/delete.schema.ts b/packages/trpc/server/routers/viewer/bookings/delete.schema.ts new file mode 100644 index 00000000000000..411d5e953b9165 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteInputSchema = z.object({ + id: z.number(), +}); + +export type TDeleteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/deletePastBookings.handler.ts b/packages/trpc/server/routers/viewer/bookings/deletePastBookings.handler.ts new file mode 100644 index 00000000000000..0f1de5c5f2e184 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/deletePastBookings.handler.ts @@ -0,0 +1,48 @@ +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TDeletePastBookingsSchema } from "./deletePastBookings.schema"; + +type DeletePastBookingsOptions = { + ctx: { + user: NonNullable; + }; + input: TDeletePastBookingsSchema; +}; + +export const deletePastBookingsHandler = async ({ ctx, input }: DeletePastBookingsOptions) => { + const { user } = ctx; + const { bookingIds } = input; + + const bookings = await prisma.booking.findMany({ + where: { + id: { in: bookingIds }, + }, + }); + + const unauthorized = bookings.some((booking) => booking.userId !== user.id); + if (unauthorized) { + throw new Error("Unauthorized: Cannot delete bookings that don't belong to you"); + } + + const result = await prisma.booking.deleteMany({ + where: { + id: { in: bookingIds }, + OR: [ + { userId: user.id }, + { + attendees: { + some: { email: user.email }, + }, + }, + ], + status: { notIn: ["CANCELLED", "REJECTED"] }, + endTime: { lt: new Date() }, + }, + }); + + return { + count: result.count, + message: `${result.count} bookings deleted successfully`, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/deletePastBookings.schema.ts b/packages/trpc/server/routers/viewer/bookings/deletePastBookings.schema.ts new file mode 100644 index 00000000000000..0e60f9d9c28c91 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/deletePastBookings.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeletePastBookingsSchema = z.object({ + bookingIds: z.number().array(), +}); + +export type TDeletePastBookingsSchema = z.infer;