diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 3bd488605fda1a..e8b5dbca440186 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -50,6 +50,7 @@ import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTit import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; +import DeleteBookingDialog from "@components/dialog/DeleteBookingDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; @@ -58,6 +59,7 @@ import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import { getPendingActions, getCancelEventAction, + getDeleteEventAction, getEditEventActions, getAfterEventActions, shouldShowPendingActions, @@ -268,6 +270,7 @@ function BookingListItem(booking: BookingItemProps) { })) as ActionType[]; const cancelEventAction = getCancelEventAction(actionContext); + const deleteEventAction = getDeleteEventAction(actionContext); const RequestSentMessage = () => { return ( @@ -289,6 +292,7 @@ function BookingListItem(booking: BookingItemProps) { const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); + const [isOpenDeleteDialog, setIsOpenDeleteDialog] = useState(false); const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { @@ -452,6 +456,11 @@ function BookingListItem(booking: BookingItemProps) { timeFormat={userTimeFormat ?? null} /> )} + {isNoShowDialogOpen && ( + {isBookingInPast && ( + + { + e.preventDefault(); + setIsOpenDeleteDialog(true); + }} + disabled={deleteEventAction.disabled} + data-testid={deleteEventAction.id} + className={deleteEventAction.disabled ? "text-muted" : undefined}> + {deleteEventAction.label} + + + )} diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index f28647c17d328f..194dd027826b2d 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -76,6 +76,18 @@ export function getCancelEventAction(context: BookingActionContext): ActionType }; } +export function getDeleteEventAction(context: BookingActionContext): ActionType { + const { t } = context; + + return { + id: "delete", + label: t("delete"), + icon: "trash", + color: "destructive", + disabled: isActionDisabled("delete", context), + }; +} + export function getVideoOptionsActions(context: BookingActionContext): ActionType[] { const { booking, isBookingInPast, isConfirmed, isCalVideoLocation, t } = context; @@ -217,6 +229,8 @@ export function isActionDisabled(actionId: string, context: BookingActionContext return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation); case "charge_card": return context.cardCharged; + case "delete": + return !isBookingInPast; default: return false; } diff --git a/apps/web/components/dialog/DeleteBookingDialog.tsx b/apps/web/components/dialog/DeleteBookingDialog.tsx new file mode 100644 index 00000000000000..b7210d2ed4b925 --- /dev/null +++ b/apps/web/components/dialog/DeleteBookingDialog.tsx @@ -0,0 +1,58 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Icon } from "@calcom/ui/components/icon"; + +interface DeleteBookingDialogProps { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingId: number; +} + +export const DeleteBookingDialog = (props: DeleteBookingDialogProps) => { + const { isOpenDialog, setIsOpenDialog, bookingId } = props; + const { t } = useLocale(); + const utils = trpc.useUtils(); + const deleteMutation = trpc.viewer.bookings.delete.useMutation({ + onSuccess: async () => { + setIsOpenDialog(false); + await utils.viewer.bookings.invalidate(); + }, + }); + + return ( + + +
+
+ +
+
+ +

+ Are you sure you want to delete this event? This action cannot be undone. +

+
+
+ + + + +
+
+ ); +}; + +export default DeleteBookingDialog; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 867264dda2f7c9..24ae0035c5a259 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -815,6 +815,7 @@ model Booking { rating Int? ratingFeedback String? noShowHost Boolean? @default(false) + deleted Boolean? scheduledTriggers WebhookScheduledTriggers[] oneTimePassword String? @unique @default(uuid()) /// @zod.email() @@ -1726,7 +1727,7 @@ model BookingDenormalized { } view BookingTimeStatusDenormalized { - id Int @id @unique + id Int @unique uid String eventTypeId Int? title String diff --git a/packages/trpc/server/routers/viewer/bookings/__tests__/delete.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/__tests__/delete.handler.test.ts new file mode 100644 index 00000000000000..11caa975ce83e8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/__tests__/delete.handler.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; + +import { deleteHandler } from "../delete.handler"; +import type { BookingsProcedureContext } from "../util"; + +vi.mock("@calcom/prisma", () => { + return { + prisma: { + booking: { + update: vi.fn(), + }, + }, + }; +}); + +describe("viewer.bookings.delete handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes a past booking by setting deleted flag only", async () => { + const past = new Date(Date.now() - 24 * 60 * 60 * 1000); + const ctx: BookingsProcedureContext = { + booking: { + id: 1, + uid: "uid", + eventTypeId: 1, + title: "title", + description: null, + customInputs: null, + responses: null, + startTime: past, + endTime: past, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + status: "accepted", + paid: false, + userId: 1, + cancellationReason: null, + rejectionReason: null, + reassignReason: null, + reassignById: null, + dynamicEventSlugRef: null, + dynamicGroupSlugRef: null, + rescheduled: null, + fromReschedule: null, + recurringEventId: null, + smsReminderNumber: null, + metadata: {}, + isRecorded: false, + iCalUID: "", + iCalSequence: 0, + rating: null, + ratingFeedback: null, + noShowHost: false, + oneTimePassword: "otp", + cancelledBy: null, + rescheduledBy: null, + tracking: null, + routingFormResponses: [], + expenseLogs: [], + attendees: [], + references: [], + destinationCalendar: null, + eventType: null, + user: null, + seatsReferences: [], + instantMeetingToken: null, + assignmentReason: [], + scheduledTriggers: [], + }, + }; + + await deleteHandler({ ctx, input: { bookingId: 1 } as any }); + + expect(prisma.booking.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { deleted: true }, + }); + }); + + it("throws when trying to delete a future booking", async () => { + const future = new Date(Date.now() + 24 * 60 * 60 * 1000); + const ctx: BookingsProcedureContext = { + booking: { + id: 2, + uid: "uid2", + eventTypeId: 1, + title: "title2", + description: null, + customInputs: null, + responses: null, + startTime: future, + endTime: future, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + status: "accepted", + paid: false, + userId: 1, + cancellationReason: null, + rejectionReason: null, + reassignReason: null, + reassignById: null, + dynamicEventSlugRef: null, + dynamicGroupSlugRef: null, + rescheduled: null, + fromReschedule: null, + recurringEventId: null, + smsReminderNumber: null, + metadata: {}, + isRecorded: false, + iCalUID: "", + iCalSequence: 0, + rating: null, + ratingFeedback: null, + noShowHost: false, + oneTimePassword: "otp", + cancelledBy: null, + rescheduledBy: null, + tracking: null, + routingFormResponses: [], + expenseLogs: [], + attendees: [], + references: [], + destinationCalendar: null, + eventType: null, + user: null, + seatsReferences: [], + instantMeetingToken: null, + assignmentReason: [], + scheduledTriggers: [], + }, + }; + + await expect(deleteHandler({ ctx, input: { bookingId: 2 } as any })).rejects.toThrow( + "Cannot delete future bookings" + ); + expect(prisma.booking.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/__tests__/get.handler.filter-deleted.test.ts b/packages/trpc/server/routers/viewer/bookings/__tests__/get.handler.filter-deleted.test.ts new file mode 100644 index 00000000000000..34cf349e5dbbb0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/__tests__/get.handler.filter-deleted.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import type { PrismaClient } from "@calcom/prisma"; + +import { getBookings } from "../get.handler"; + +// Mock prisma minimal methods used inside getBookings +vi.mock("@calcom/prisma", () => { + return { + prisma: { + membership: { findMany: vi.fn().mockResolvedValue([]) }, + booking: { + groupBy: vi.fn().mockResolvedValue([]), + }, + }, + }; +}); + +describe("getBookings filters out deleted bookings", () => { + beforeEach(() => vi.clearAllMocks()); + + it("applies where Booking.deleted = false and returns non-deleted rows", async () => { + // Rows returned from selecting Booking (should not include deleted ones if filtering is correct) + const nonDeletedRows = [ + { + id: 1, + title: "foo", + startTime: new Date(), + endTime: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + uid: "u1", + paid: false, + }, + { + id: 2, + title: "bar", + startTime: new Date(), + endTime: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + uid: "u2", + paid: false, + }, + ]; + + // Chainable mocks for union subquery + const selectAllUnionMock = vi.fn().mockReturnThis(); + const ifUnionMock = vi.fn().mockReturnThis(); + const orderByUnionMock = vi.fn().mockReturnThis(); + const limitUnionMock = vi.fn().mockReturnThis(); + const offsetUnionMock = vi.fn().mockReturnThis(); + const compileUnionMock = vi.fn().mockReturnValue({ sql: "compiled" }); + const executeQueryMock = vi.fn().mockResolvedValue({ rows: [{ id: 1 }, { id: 2 }] }); + + // Chainable mocks for Booking select + const whereBookingMock = vi.fn().mockReturnThis(); + const selectBookingMock = vi.fn().mockReturnThis(); + const orderByBookingMock = vi.fn().mockReturnThis(); + const executeBookingMock = vi.fn().mockResolvedValue(nonDeletedRows); + + const kysely = { + selectFrom: vi.fn((table: unknown) => { + if (table === "Booking") { + return { + where: whereBookingMock, + select: selectBookingMock, + orderBy: orderByBookingMock, + execute: executeBookingMock, + }; + } + // union path + return { + selectAll: selectAllUnionMock, + $if: ifUnionMock, + orderBy: orderByUnionMock, + limit: limitUnionMock, + offset: offsetUnionMock, + compile: compileUnionMock, + }; + }), + executeQuery: executeQueryMock, + } as unknown as any; + + const { bookings } = await getBookings({ + user: { id: 1, email: "a@b.com" }, + prisma: (await import("@calcom/prisma")).prisma as unknown as PrismaClient, + kysely, + bookingListingByStatus: ["upcoming"], + filters: {}, + take: 10, + skip: 0, + }); + + // Ensure we got back the same rows + expect(bookings.map((b: any) => b.id)).toEqual([1, 2]); + + // Ensure we applied deleted filter on Booking builder + const calls = whereBookingMock.mock.calls.map((args) => args); + expect(calls).toContainEqual(["Booking.deleted", "=", false]); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index d5eaff4f0505c6..3dbdc1ca5588cf 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 "./delete.schema"; import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; @@ -68,6 +69,15 @@ export const bookingsRouter = router({ }); }), + delete: bookingsProcedure.input(ZDeleteBookingInputSchema).mutation(async ({ input, ctx }) => { + const { deleteHandler } = await import("./delete.handler"); + + return deleteHandler({ + ctx, + input, + }); + }), + getBookingAttendees: authedProcedure .input(ZGetBookingAttendeesInputSchema) .query(async ({ input, ctx }) => { 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..733ad836db009d --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/delete.handler.ts @@ -0,0 +1,28 @@ +import { prisma } from "@calcom/prisma"; + +import type { ZDeleteBookingInputSchema } from "./delete.schema"; +import type { BookingsProcedureContext } from "./util"; + +type DeleteOptions = { + ctx: BookingsProcedureContext; + input: ZDeleteBookingInputSchema; +}; + +export async function deleteHandler({ ctx }: DeleteOptions) { + // Soft delete: mark as cancelled and set deleted flag + const { booking } = ctx; + + // Check if booking is in the past + if (booking.endTime > new Date()) { + throw new Error("Cannot delete future bookings"); + } + + await prisma.booking.update({ + where: { id: booking.id }, + data: { + deleted: true, + }, + }); + + return { message: "Booking deleted" }; +} 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..ac973d9b176b4e --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/delete.schema.ts @@ -0,0 +1,3 @@ +import { commonBookingSchema } from "./types"; + +export const ZDeleteBookingInputSchema = commonBookingSchema; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 8974e1183ab9b7..6371f558f9ed7a 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -432,6 +432,7 @@ export async function getBookings({ "in", bookingsFromUnion.map((booking) => booking.id) ) + .where("Booking.deleted", "=", false) .select((eb) => [ "Booking.id", "Booking.title",