diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f673aa9f6440fb..dd4e11f5e65eb1 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; +import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; @@ -132,6 +133,7 @@ function BookingListItem(booking: BookingItemProps) { const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState(false); const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState(false); + const [isReportDialogOpen, setIsReportDialogOpen] = useState(false); const cardCharged = booking?.payment[0]?.success; const attendeeList = booking.attendees.map((attendee) => { @@ -402,6 +404,28 @@ function BookingListItem(booking: BookingItemProps) { (action.id === "view_recordings" && !booking.isRecorded), })) as ActionType[]; + const hasBeenReported = booking.reportLogs && booking.reportLogs.length > 0; + + const reportAction: ActionType = hasBeenReported + ? { + id: "report", + label: t("already_reported"), + icon: "flag", + disabled: true, + } + : { + id: "report", + label: t("report"), + icon: "flag", + disabled: false, + onClick: () => setIsReportDialogOpen(true), + }; + + const shouldShowIndividualReportButton = isTabRecurring || isPending || isCancelled; + + const shouldShowReportInThreeDotsMenu = + shouldShowEditActions(actionContext) && !shouldShowIndividualReportButton; + return ( <> + {shouldShowReportInThreeDotsMenu && ( + + + {reportAction.label} + + + )} )} {shouldShowRecurringCancelAction(actionContext) && } + {shouldShowIndividualReportButton && ( +
+
+ )} {isRejected &&
{t("rejected")}
} {isCancelled && booking.rescheduled && (
@@ -726,6 +781,21 @@ function BookingListItem(booking: BookingItemProps) { />
+ setIsReportDialogOpen(false)} + bookingId={booking.id} + bookingTitle={booking.title} + isUpcoming={isUpcoming} + isCancelled={isCancelled} + onSuccess={async () => { + // Invalidate all booking queries to ensure UI reflects the changes + await utils.viewer.bookings.invalidate(); + // Also invalidate any cached booking data + await utils.invalidate(); + }} + /> + {isBookingFromRoutingForm && ( { const { t } = useLocale(); + const hasBeenReported = booking.reportLogs && booking.reportLogs.length > 0; return (
@@ -768,6 +839,14 @@ const BookingItemBadges = ({ )} + {hasBeenReported && ( + + {t("reported")}:{" "} + {booking.reportLogs?.[0]?.reason + ? t(booking.reportLogs?.[0]?.reason?.toLowerCase()) + : t("unavailable")} + + )} {booking.eventType?.team && ( {booking.eventType.team.name} diff --git a/apps/web/components/booking/bookingActions.report.test.ts b/apps/web/components/booking/bookingActions.report.test.ts new file mode 100644 index 00000000000000..a778b194d0e2f9 --- /dev/null +++ b/apps/web/components/booking/bookingActions.report.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from "vitest"; + +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; + +import { getReportActions, type BookingActionContext } from "./bookingActions"; + +const mockT = (key: string) => key; + +function createMockContext(overrides: Partial = {}): BookingActionContext { + const now = new Date(); + const startTime = new Date(now.getTime() + 24 * 60 * 60 * 1000); // Tomorrow + const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour later + + return { + booking: { + id: 1, + uid: "test-uid", + title: "Test Booking", + status: BookingStatus.ACCEPTED, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + createdAt: now, + updatedAt: now, + userPrimaryEmail: "user@example.com", + user: { + id: 1, + name: "Test User", + email: "user@example.com", + }, + eventType: { + id: 1, + title: "Test Event", + schedulingType: SchedulingType.ROUND_ROBIN, + allowReschedulingPastBookings: false, + recurringEvent: null, + eventTypeColor: null, + price: 0, + currency: "USD", + metadata: null, + length: 60, + slug: "test-event", + team: null, + hosts: [], + }, + attendees: [], + payment: [], + paid: false, + isRecorded: false, + reportLogs: [], + rescheduler: null, + rescheduled: null, + fromReschedule: null, + responses: null, + location: null, + description: null, + customInputs: [], + references: [], + recurringEventId: null, + seatsReferences: [], + metadata: null, + routedFromRoutingFormReponse: null, + assignmentReason: [], + ...overrides.booking, + listingStatus: "upcoming" as const, + recurringInfo: undefined, + loggedInUser: { + userId: 1, + userTimeZone: "UTC", + userTimeFormat: 24, + userEmail: "user@example.com", + }, + isToday: false, + }, + isUpcoming: true, + isOngoing: false, + isBookingInPast: false, + isCancelled: false, + isConfirmed: true, + isRejected: false, + isPending: false, + isRescheduled: false, + isRecurring: false, + isTabRecurring: false, + isTabUnconfirmed: false, + isBookingFromRoutingForm: false, + isDisabledCancelling: false, + isDisabledRescheduling: false, + isCalVideoLocation: true, + showPendingPayment: false, + cardCharged: false, + isAttendee: false, + attendeeList: [ + { + name: "Test Attendee", + email: "attendee@example.com", + id: 1, + noShow: false, + phoneNumber: null, + }, + ], + getSeatReferenceUid: () => undefined, + t: mockT, + ...overrides, + }; +} + +describe("Report Booking Actions", () => { + describe("getReportActions", () => { + it("should return report action for unreported booking", () => { + const context = createMockContext(); + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0]).toEqual({ + id: "report", + label: "report", + icon: "flag", + disabled: false, + }); + }); + + it("should return report action for any booking status", () => { + const statuses = [ + BookingStatus.ACCEPTED, + BookingStatus.CANCELLED, + BookingStatus.PENDING, + BookingStatus.REJECTED, + ]; + + statuses.forEach((status) => { + const context = createMockContext(); + context.booking.status = status; + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("report"); + }); + }); + + it("should return report action for past bookings", () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // Yesterday + const context = createMockContext({ + isUpcoming: false, + isBookingInPast: true, + }); + context.booking.startTime = pastDate.toISOString(); + context.booking.endTime = new Date(pastDate.getTime() + 60 * 60 * 1000).toISOString(); + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("report"); + }); + + it("should return report action for upcoming bookings", () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // Tomorrow + const context = createMockContext({ + isUpcoming: true, + isBookingInPast: false, + }); + context.booking.startTime = futureDate.toISOString(); + context.booking.endTime = new Date(futureDate.getTime() + 60 * 60 * 1000).toISOString(); + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("report"); + }); + + it("should return report action for team bookings", () => { + const context = createMockContext(); + context.booking.eventType = { + ...context.booking.eventType, + id: 1, + title: "Team Event", + schedulingType: SchedulingType.COLLECTIVE, + team: { + id: 1, + name: "Test Team", + slug: "test-team", + }, + }; + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("report"); + }); + + it("should return report action for individual bookings", () => { + const context = createMockContext(); + context.booking.eventType = { + ...context.booking.eventType, + id: 2, + title: "Individual Event", + schedulingType: null, + team: null, + }; + const actions = getReportActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("report"); + }); + }); +}); diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index f98e99266a5d2a..1b4d97b4a4f20b 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -188,6 +188,21 @@ export function getAfterEventActions(context: BookingActionContext): ActionType[ return actions.filter(Boolean) as ActionType[]; } +export function getReportActions(context: BookingActionContext): ActionType[] { + const { booking: _booking, t } = context; + + const actions: ActionType[] = [ + { + id: "report", + label: t("report"), + icon: "flag", + disabled: false, + }, + ]; + + return actions; +} + export function shouldShowPendingActions(context: BookingActionContext): boolean { const { isPending, isUpcoming, isCancelled } = context; return isPending && isUpcoming && !isCancelled; @@ -204,8 +219,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext): } export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { - const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } = - context; + const { + booking, + isBookingInPast, + isDisabledRescheduling, + isDisabledCancelling, + isPending: _isPending, + isConfirmed: _isConfirmed, + } = context; switch (actionId) { case "reschedule": @@ -225,7 +246,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext } export function getActionLabel(actionId: string, context: BookingActionContext): string { - const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context; + const { booking: _booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context; switch (actionId) { case "reject": @@ -240,6 +261,8 @@ export function getActionLabel(actionId: string, context: BookingActionContext): : t("mark_as_no_show"); case "charge_card": return cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"); + case "report": + return t("report"); default: return t(actionId); } diff --git a/apps/web/components/dialog/ReportBookingDialog.test.tsx b/apps/web/components/dialog/ReportBookingDialog.test.tsx new file mode 100644 index 00000000000000..f2ac6ad3a1b714 --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.test.tsx @@ -0,0 +1,147 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { ReportReason } from "@calcom/prisma/enums"; + +import { ReportBookingDialog } from "./ReportBookingDialog"; + +// Mock the TRPC hook +const mockMutate = vi.fn(); +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + bookings: { + reportBooking: { + useMutation: () => ({ + mutate: mockMutate, + isPending: false, + }), + }, + }, + }, + }, +})); + +// Mock the useLocale hook +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + }), +})); + +// Mock the toast +vi.mock("@calcom/ui/components/toast", () => ({ + showToast: vi.fn(), +})); + +describe("ReportBookingDialog", () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + bookingId: 1, + bookingTitle: "Test Booking", + isUpcoming: true, + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the dialog when open", () => { + render(); + + expect(screen.getByText("report_booking")).toBeInTheDocument(); + expect(screen.getByText("report_booking_subtitle")).toBeInTheDocument(); + expect(screen.getByText("reason")).toBeInTheDocument(); + expect(screen.getByText("additional_comments")).toBeInTheDocument(); + }); + + it("should not render when closed", () => { + render(); + + expect(screen.queryByText("report_booking")).not.toBeInTheDocument(); + }); + + it("should show cancel booking option for upcoming bookings", () => { + render(); + + expect(screen.getByText("cancel_booking_description")).toBeInTheDocument(); + expect(screen.getByText("attendee_not_notified_report")).toBeInTheDocument(); + }); + + it("should not show cancel booking option for past bookings", () => { + render(); + + expect(screen.queryByText("cancel_booking_description")).not.toBeInTheDocument(); + }); + + it("should submit report with correct data", async () => { + render(); + + // Fill out the form + // The reason select defaults to "spam" so no need to change it + + const commentsTextarea = screen.getByRole("textbox"); + fireEvent.change(commentsTextarea, { target: { value: "Test comment" } }); + + const submitButton = screen.getByRole("button", { name: /report/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith({ + bookingId: 1, + reason: ReportReason.SPAM, + description: "Test comment", + cancelBooking: false, + }); + }); + }); + + it("should submit report with cancel booking when checkbox is checked", async () => { + render(); + + // Check the cancel booking checkbox + const cancelCheckbox = screen.getByRole("checkbox"); + fireEvent.click(cancelCheckbox); + + // Fill out the form + // The reason select defaults to "spam" so no need to change it + + const submitButton = screen.getByRole("button", { name: /report_and_cancel/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith({ + bookingId: 1, + reason: ReportReason.SPAM, + description: undefined, + cancelBooking: true, + }); + }); + }); + + it("should call onClose when cancel button is clicked", () => { + const onClose = vi.fn(); + render(); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should show correct button text based on cancel booking state", () => { + render(); + + // Initially should show "Report" + expect(screen.getByRole("button", { name: /^report$/i })).toBeInTheDocument(); + + // Check the cancel booking checkbox + const cancelCheckbox = screen.getByRole("checkbox"); + fireEvent.click(cancelCheckbox); + + // Should now show "Report and Cancel" + expect(screen.getByRole("button", { name: /report_and_cancel/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/dialog/ReportBookingDialog.tsx b/apps/web/components/dialog/ReportBookingDialog.tsx new file mode 100644 index 00000000000000..8c30df863e7bfb --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.tsx @@ -0,0 +1,165 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ReportReason } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Form, TextAreaField, CheckboxField, SelectField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +interface ReportBookingDialogProps { + isOpen: boolean; + onClose: () => void; + bookingId: number; + bookingTitle: string; + isUpcoming: boolean; + isCancelled?: boolean; + onSuccess?: () => void; +} + +interface ReportFormData { + reason: ReportReason; + description?: string; + cancelBooking: boolean; +} + +const REPORT_REASON_OPTIONS = [ + { value: ReportReason.SPAM, label: "spam" }, + { value: ReportReason.DONT_KNOW_PERSON, label: "dont_know_person" }, + { value: ReportReason.OTHER, label: "other" }, +]; + +export function ReportBookingDialog({ + isOpen, + onClose, + bookingId, + bookingTitle: _bookingTitle, + isUpcoming, + isCancelled = false, + onSuccess, +}: ReportBookingDialogProps) { + const { t } = useLocale(); + + const form = useForm({ + defaultValues: { + reason: ReportReason.SPAM, + description: "", + cancelBooking: false, + }, + }); + + const reportBookingMutation = trpc.viewer.bookings.reportBooking.useMutation({ + onSuccess: (data) => { + showToast(data.message, "success"); + onSuccess?.(); + onClose(); + form.reset({ + reason: ReportReason.SPAM, + description: "", + cancelBooking: false, + }); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const onSubmit = (data: ReportFormData) => { + reportBookingMutation.mutate({ + bookingId, + reason: data.reason, + description: data.description || undefined, + cancelBooking: data.cancelBooking, + }); + }; + + const handleClose = () => { + if (!reportBookingMutation.isPending) { + onClose(); + form.reset({ + reason: ReportReason.SPAM, + description: "", + cancelBooking: false, + }); + } + }; + + const translatedOptions = useMemo( + () => REPORT_REASON_OPTIONS.map((option) => ({ ...option, label: t(option.label) })), + [t] + ); + + const cancelBooking = form.watch("cancelBooking"); + + return ( + { + if (!open) handleClose(); + }}> + + + +
+
+ ( + option.value === field.value)} + onChange={(selectedOption) => { + if (selectedOption) { + field.onChange(selectedOption.value); + } + }} + required + /> + )} + /> + + + + {isUpcoming && !isCancelled && ( +
+ +

{t("attendee_not_notified_report")}

+
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 24e86413baec21..666664fa737dc5 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -5,6 +5,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmailsAndSMS } from "@calcom/emails"; +import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation"; import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund"; @@ -17,7 +18,6 @@ import { } from "@calcom/features/webhooks/lib/scheduleTrigger"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; -import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; @@ -79,6 +79,7 @@ async function handler(input: CancelBookingInput) { cancelledBy, cancelSubsequentBookings, internalNote, + fromReport, } = bookingCancelInput.parse(body); const bookingToDelete = await getBookingToDelete(id, uid); const { @@ -117,7 +118,7 @@ async function handler(input: CancelBookingInput) { const isCancellationUserHost = bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy; - if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost) { + if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost && !fromReport) { throw new HttpError({ statusCode: 400, message: "Cancellation reason is required when you are the host", diff --git a/packages/features/watchlist/watchlist.repository.interface.ts b/packages/features/watchlist/watchlist.repository.interface.ts index 7ba722454be1cb..f8bcd87f918feb 100644 --- a/packages/features/watchlist/watchlist.repository.interface.ts +++ b/packages/features/watchlist/watchlist.repository.interface.ts @@ -1,5 +1,17 @@ +import type { ReportReason } from "@calcom/prisma/enums"; + import type { Watchlist } from "./watchlist.model"; export interface IWatchlistRepository { getBlockedEmailInWatchlist(email: string): Promise; + createBookingReport(params: { + bookingId: number; + reportedById: number; + reason: ReportReason; + description?: string; + cancelled: boolean; + organizationId?: number; + }): Promise<{ watchlistEntry: unknown; reportLog: unknown }>; + isBookingReported(bookingId: number): Promise; + getBookingReport(bookingId: number): Promise; } diff --git a/packages/features/watchlist/watchlist.repository.ts b/packages/features/watchlist/watchlist.repository.ts index 0891a39ef78dc2..17794aec818755 100644 --- a/packages/features/watchlist/watchlist.repository.ts +++ b/packages/features/watchlist/watchlist.repository.ts @@ -1,7 +1,8 @@ import { captureException } from "@sentry/nextjs"; import db from "@calcom/prisma"; -import { WatchlistType, WatchlistSeverity } from "@calcom/prisma/enums"; +import type { ReportReason } from "@calcom/prisma/enums"; +import { WatchlistType, WatchlistSeverity, WatchlistAction } from "@calcom/prisma/enums"; import type { IWatchlistRepository } from "./watchlist.repository.interface"; @@ -94,4 +95,94 @@ export class WatchlistRepository implements IWatchlistRepository { throw err; } } + + async createBookingReport({ + bookingId, + reportedById, + reason, + description, + cancelled, + organizationId, + }: { + bookingId: number; + reportedById: number; + reason: ReportReason; + description?: string; + cancelled: boolean; + organizationId?: number; + }) { + try { + return await db.$transaction(async (tx) => { + const watchlistEntry = await tx.watchlist.create({ + data: { + type: WatchlistType.BOOKING_REPORT, + value: bookingId.toString(), + description: `${reason}: ${description || ""}`, + action: WatchlistAction.REPORT, + severity: WatchlistSeverity.LOW, + createdById: reportedById, + organizationId, + }, + }); + + const reportLog = await tx.bookingReportLog.create({ + data: { + bookingId, + reportedById, + reason, + cancelled, + watchlistId: watchlistEntry.id, + }, + }); + + return { watchlistEntry, reportLog }; + }); + } catch (err) { + captureException(err); + throw err; + } + } + + async isBookingReported(bookingId: number): Promise { + try { + const existingReport = await db.bookingReportLog.findUnique({ + where: { bookingId }, + }); + return !!existingReport; + } catch (err) { + captureException(err); + throw err; + } + } + + async getBookingReport(bookingId: number) { + try { + return await db.bookingReportLog.findUnique({ + where: { bookingId }, + select: { + id: true, + bookingId: true, + reportedById: true, + reason: true, + cancelled: true, + watchlistId: true, + createdAt: true, + watchlist: { + select: { + id: true, + type: true, + value: true, + description: true, + action: true, + severity: true, + createdAt: true, + }, + }, + }, + }); + } catch (err) { + captureException(err); + throw err; + } + } } diff --git a/packages/prisma/migrations/20250823231324_add_booking_report/migration.sql b/packages/prisma/migrations/20250823231324_add_booking_report/migration.sql new file mode 100644 index 00000000000000..0d5574e342dfd9 --- /dev/null +++ b/packages/prisma/migrations/20250823231324_add_booking_report/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'dont_know_person', 'OTHER'); + +-- CreateTable +CREATE TABLE "BookingReport" ( + "id" TEXT NOT NULL, + "bookingId" INTEGER NOT NULL, + "reportedById" INTEGER NOT NULL, + "reason" "ReportReason" NOT NULL, + "description" TEXT, + "cancelled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingReport_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BookingReport_bookingId_key" ON "BookingReport"("bookingId"); + +-- CreateIndex +CREATE INDEX "BookingReport_bookingId_idx" ON "BookingReport"("bookingId"); + +-- CreateIndex +CREATE INDEX "BookingReport_reportedById_idx" ON "BookingReport"("reportedById"); + +-- AddForeignKey +ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20250924100227_replace_booking_report_with_watchlist/migration.sql b/packages/prisma/migrations/20250924100227_replace_booking_report_with_watchlist/migration.sql new file mode 100644 index 00000000000000..18048dc693eb3c --- /dev/null +++ b/packages/prisma/migrations/20250924100227_replace_booking_report_with_watchlist/migration.sql @@ -0,0 +1,57 @@ +/* + Warnings: + + - You are about to drop the `BookingReport` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- AlterEnum +ALTER TYPE "WatchlistType" ADD VALUE 'BOOKING_REPORT'; + +-- DropForeignKey +ALTER TABLE "BookingReport" DROP CONSTRAINT "BookingReport_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingReport" DROP CONSTRAINT "BookingReport_reportedById_fkey"; + +-- DropForeignKey +ALTER TABLE "Watchlist" DROP CONSTRAINT "Watchlist_createdById_fkey"; + +-- DropTable +DROP TABLE "BookingReport"; + +-- CreateTable +CREATE TABLE "BookingReportLog" ( + "id" TEXT NOT NULL, + "bookingId" INTEGER NOT NULL, + "reportedById" INTEGER NOT NULL, + "reason" "ReportReason" NOT NULL, + "cancelled" BOOLEAN NOT NULL DEFAULT false, + "watchlistId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingReportLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BookingReportLog_bookingId_key" ON "BookingReportLog"("bookingId"); + +-- CreateIndex +CREATE INDEX "BookingReportLog_bookingId_idx" ON "BookingReportLog"("bookingId"); + +-- CreateIndex +CREATE INDEX "BookingReportLog_reportedById_idx" ON "BookingReportLog"("reportedById"); + +-- CreateIndex +CREATE INDEX "BookingReportLog_watchlistId_idx" ON "BookingReportLog"("watchlistId"); + +-- AddForeignKey +ALTER TABLE "BookingReportLog" ADD CONSTRAINT "BookingReportLog_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingReportLog" ADD CONSTRAINT "BookingReportLog_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingReportLog" ADD CONSTRAINT "BookingReportLog_watchlistId_fkey" FOREIGN KEY ("watchlistId") REFERENCES "Watchlist"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Watchlist" ADD CONSTRAINT "Watchlist_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 795becadcd5e6b..22ee1f434b9569 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -462,6 +462,7 @@ model User { whitelistWorkflows Boolean @default(false) calAiPhoneNumbers CalAiPhoneNumber[] agents Agent[] + bookingReportLogs BookingReportLog[] @@unique([email]) @@unique([email, username]) @@ -781,6 +782,29 @@ enum BookingStatus { AWAITING_HOST @map("awaiting_host") } +enum ReportReason { + SPAM + DONT_KNOW_PERSON @map("dont_know_person") + OTHER +} + +model BookingReportLog { + id String @id @default(uuid()) + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int @unique + reportedBy User @relation(fields: [reportedById], references: [id], onDelete: Cascade) + reportedById Int + reason ReportReason + cancelled Boolean @default(false) + watchlistId String + watchlist Watchlist @relation(fields: [watchlistId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@index([bookingId]) + @@index([reportedById]) + @@index([watchlistId]) +} + model Booking { id Int @id @default(autoincrement()) uid String @unique @@ -847,6 +871,7 @@ model Booking { tracking Tracking? routingFormResponses RoutingFormResponseDenormalized[] expenseLogs CreditExpenseLog[] + reportLogs BookingReportLog[] @@index([eventTypeId]) @@index([userId]) @@ -2242,6 +2267,7 @@ enum WatchlistType { EMAIL DOMAIN USERNAME + BOOKING_REPORT } enum WatchlistSeverity { @@ -2274,6 +2300,8 @@ model Watchlist { updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id]) updatedById Int? + bookingReportLogs BookingReportLog[] + @@unique([type, value, organizationId]) @@index([type, value, organizationId, action]) } diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index afddcb2146b3da..ce3c5f13634ef6 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -277,6 +277,7 @@ export const bookingCancelSchema = z.object({ cancellationReason: z.string().optional(), seatReferenceUid: z.string().optional(), cancelledBy: z.string().email({ message: "Invalid email" }).optional(), + fromReport: z.boolean().optional(), // Indicates cancellation is from report booking internalNote: z .object({ id: z.number(), diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index d5eaff4f0505c6..5c197776b1eb2d 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -8,20 +8,10 @@ import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema"; +import { ZReportBookingInputSchema } from "./reportBooking.schema"; import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; import { bookingsProcedure } from "./util"; -type BookingsRouterHandlerCache = { - get?: typeof import("./get.handler").getHandler; - requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; - editLocation?: typeof import("./editLocation.handler").editLocationHandler; - addGuests?: typeof import("./addGuests.handler").addGuestsHandler; - confirm?: typeof import("./confirm.handler").confirmHandler; - getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; - find?: typeof import("./find.handler").getHandler; - getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler; -}; - export const bookingsRouter = router({ get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { const { getHandler } = await import("./get.handler"); @@ -98,4 +88,13 @@ export const bookingsRouter = router({ input, }); }), + + reportBooking: authedProcedure.input(ZReportBookingInputSchema).mutation(async ({ input, ctx }) => { + const { reportBookingHandler } = await import("./reportBooking.handler"); + + return reportBookingHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 9aea7ee396099d..cd23c9d9afbc07 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -12,7 +12,7 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { PrismaClient } from "@calcom/prisma"; -import type { Booking, Prisma, Prisma as PrismaClientType } from "@calcom/prisma/client"; +import type { Booking, Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -590,6 +590,19 @@ export async function getBookings({ .orderBy("AssignmentReason.createdAt", "desc") .limit(1) ).as("assignmentReason"), + jsonArrayFrom( + eb + .selectFrom("BookingReportLog") + .select([ + "BookingReportLog.id", + "BookingReportLog.reason", + "BookingReportLog.cancelled", + "BookingReportLog.createdAt", + "BookingReportLog.reportedById", + ]) + .whereRef("BookingReportLog.bookingId", "=", "Booking.id") + .orderBy("BookingReportLog.createdAt", "desc") + ).as("reportLogs"), ]) .orderBy(orderBy.key, orderBy.order) .execute() @@ -845,7 +858,7 @@ async function getEventTypeIdsFromEventTypeIdsFilter(prisma: PrismaClient, event async function getEventTypeIdsWhereUserIsAdminOrOwner( prisma: PrismaClient, - membershipCondition: PrismaClientType.MembershipListRelationFilter + membershipCondition: Prisma.MembershipListRelationFilter ) { const [directTeamEventTypeIds, parentTeamEventTypeIds] = await Promise.all([ prisma.eventType @@ -888,7 +901,7 @@ async function getEventTypeIdsWhereUserIsAdminOrOwner( */ async function getUserIdsAndEmailsWhereUserIsAdminOrOwner( prisma: PrismaClient, - membershipCondition: PrismaClientType.MembershipListRelationFilter + membershipCondition: Prisma.MembershipListRelationFilter ): Promise<[number[], string[]]> { const users = await prisma.user.findMany({ where: { diff --git a/packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts b/packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts new file mode 100644 index 00000000000000..db7dfb1d227209 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts @@ -0,0 +1,251 @@ +import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; +import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { TRPCError } from "@trpc/server"; + +import type { TReportBookingInputSchema } from "./reportBooking.schema"; + +type ReportBookingOptions = { + ctx: { + user: NonNullable; + }; + input: TReportBookingInputSchema; +}; + +export const reportBookingHandler = async ({ ctx, input }: ReportBookingOptions) => { + const { user } = ctx; + const { bookingId, reason, description, cancelBooking } = input; + + const booking = await prisma.booking.findUnique({ + where: { + id: bookingId, + }, + select: { + id: true, + userId: true, + uid: true, + startTime: true, + status: true, + recurringEventId: true, + attendees: { + select: { + email: true, + }, + }, + eventType: { + select: { + teamId: true, + team: { + select: { + id: true, + name: true, + parentId: true, + }, + }, + }, + }, + reportLogs: { + select: { + id: true, + }, + }, + }, + }); + + if (!booking) { + throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" }); + } + + const isBookingOwner = booking.userId === user.id; + const isAttendee = booking.attendees.some((attendee) => attendee.email === user.email); + + // Check if user is team admin/owner for the event type's team + let isTeamAdminOrOwner = false; + if (booking.eventType?.teamId) { + isTeamAdminOrOwner = Boolean( + await MembershipRepository.getAdminOrOwnerMembership(user.id, booking.eventType.teamId) + ); + } + + // Check if user can access this booking through team membership + // This reuses the same logic as get.handler.ts + let canAccessThroughTeamMembership = false; + + // Get all memberships where user is ADMIN/OWNER + const teamMemberEmailList = await getUserEmails(user.id, user?.profile?.organizationId); + + if (teamMemberEmailList.length > 0) { + // Check if any booking attendees are team members where user is admin/owner + const hasTeamMemberAttendee = booking.attendees.some((attendee) => + teamMemberEmailList.includes(attendee.email) + ); + + // Check if booking owner is a team member where user is admin/owner + let isBookingOwnerTeamMember = false; + if (booking.userId) { + const bookingOwner = await prisma.user.findUnique({ + where: { id: booking.userId }, + select: { email: true }, + }); + isBookingOwnerTeamMember = !!(bookingOwner && teamMemberEmailList.includes(bookingOwner.email)); + } + + canAccessThroughTeamMembership = hasTeamMemberAttendee || isBookingOwnerTeamMember; + } + + if (!isBookingOwner && !isAttendee && !isTeamAdminOrOwner && !canAccessThroughTeamMembership) { + throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this booking" }); + } + + // Check if booking has already been reported + const watchlistRepository = new WatchlistRepository(); + if (booking.reportLogs && booking.reportLogs.length > 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "This booking has already been reported" }); + } + + if (booking.recurringEventId) { + // For recurring bookings, check if any booking in the series has been reported + const existingRecurringReport = await prisma.bookingReportLog.findFirst({ + where: { + booking: { + recurringEventId: booking.recurringEventId, + }, + }, + }); + + if (existingRecurringReport) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This recurring booking series has already been reported", + }); + } + } + + // For recurring bookings, report all remaining instances + let reportedBookingIds: number[] = [bookingId]; + + if (booking.recurringEventId) { + // Get all remaining bookings in the series from the selected occurrence onward + const remainingRecurringBookings = await prisma.booking.findMany({ + where: { + recurringEventId: booking.recurringEventId, + startTime: { gte: booking.startTime }, + }, + select: { id: true }, + orderBy: { startTime: "asc" }, + }); + reportedBookingIds = remainingRecurringBookings.map((b) => b.id); + } + + // Create reports for all relevant bookings using WatchlistRepository + let createdCount = 0; + for (const id of reportedBookingIds) { + try { + await watchlistRepository.createBookingReport({ + bookingId: id, + reportedById: user.id, + reason, + description, + cancelled: cancelBooking, + organizationId: user.organizationId ?? undefined, + }); + createdCount++; + } catch (error) { + console.warn(`Failed to create report for booking ${id}:`, error); + } + } + + const reportedBooking = { id: reportedBookingIds[0] }; + + // Cancel booking if requested and conditions are met + let cancellationError = null; + if ( + cancelBooking && + (booking.status === BookingStatus.ACCEPTED || booking.status === BookingStatus.PENDING) && + new Date(booking.startTime) > new Date() + ) { + try { + await handleCancelBooking({ + bookingData: { + uid: booking.uid, + cancelledBy: user.email, + fromReport: true, // Bypass cancellation reason requirement for report cancellations + allRemainingBookings: booking.recurringEventId ? true : undefined, + }, + userId: user.id, + }); + } catch (error) { + cancellationError = error; + console.error("Failed to cancel booking after reporting:", error); + } + } + + const isRecurring = Boolean(booking.recurringEventId) && createdCount > 1; + const baseMessage = isRecurring ? `${createdCount} recurring bookings reported` : "Booking reported"; + + return { + success: true, + message: cancellationError + ? `${baseMessage} successfully, but cancellation failed` + : cancelBooking + ? `${baseMessage} and cancelled successfully` + : `${baseMessage} successfully`, + bookingId: reportedBooking.id, + reportedCount: createdCount, + cancellationError: cancellationError ? String(cancellationError) : undefined, + }; +}; + +async function getUserEmails(userId: number, organizationId?: number | null) { + const memberships = await prisma.membership.findMany({ + where: { + userId, + accepted: true, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + ...((organizationId ?? null) !== null + ? { + OR: [{ teamId: organizationId as number }, { team: { parentId: organizationId as number } }], + } + : {}), + }, + select: { + team: { + select: { + members: { + where: { + accepted: true, + }, + select: { + user: { + select: { + email: true, + }, + }, + }, + }, + }, + }, + }, + }); + + const userEmails = new Set(); + + for (const membership of memberships) { + if (membership.team) { + for (const member of membership.team.members) { + if (member.user) { + userEmails.add(member.user.email); + } + } + } + } + + return Array.from(userEmails); +} diff --git a/packages/trpc/server/routers/viewer/bookings/reportBooking.schema.ts b/packages/trpc/server/routers/viewer/bookings/reportBooking.schema.ts new file mode 100644 index 00000000000000..f161dd122e30f7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/reportBooking.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { ReportReason } from "@calcom/prisma/enums"; + +export const ZReportBookingInputSchema = z.object({ + bookingId: z.number(), + reason: z.nativeEnum(ReportReason), + description: z.string().optional(), + cancelBooking: z.boolean().default(false), +}); + +export type TReportBookingInputSchema = z.infer;