From 06bb99414521c620d7c48656c4f8a77406f5feb4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:12:21 -0500 Subject: [PATCH] fix: Improve wrong assignment report (#27162) * Pass already fetched booking * Fetch the assignment reason * Update tests * Label as first assignment reason * Pass single booking object * Remove unused repository method * Only pass bookingUid to handler * Rename booking `assignmentReason` to `assignmentReasonSortedByCreatedAt` * fix: use renamed assignmentReasonSortedByCreatedAt property Co-Authored-By: joe@cal.com * Remove take statement * fix: address Udit's review comments - Restore BookingAccessService for access control checks - Rename repository method from findByUidForWrongAssignmentReport to findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason Co-Authored-By: joe@cal.com * test: update tests to use BookingAccessService mock Co-Authored-By: joe@cal.com * fix: use latest assignment reason in BookingDetailsSheet for consistency Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 15 +- .../actions/BookingActionsDropdown.tsx | 9 +- .../booking/actions/bookingActions.test.ts | 2 +- .../dialog/WrongAssignmentDialog.tsx | 67 +++-- .../components/BookingDetailsSheet.tsx | 8 +- apps/web/public/static/locales/en/common.json | 1 + .../repositories/BookingRepository.ts | 102 +++---- .../routers/viewer/bookings/get.handler.ts | 5 +- .../reportWrongAssignment.handler.test.ts | 266 ++++++++++-------- .../bookings/reportWrongAssignment.handler.ts | 32 +-- .../bookings/reportWrongAssignment.schema.ts | 2 +- 11 files changed, 277 insertions(+), 232 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index ae32df69db..1c3ca971c7 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -552,19 +552,14 @@ function BookingListItem(booking: BookingItemProps) { userTimeZone={userTimeZone} isRescheduled={isRescheduled} onAssignmentReasonClick={ - booking.assignmentReason.length > 0 ? () => setIsOpenRoutingTraceSheet(true) : undefined + booking.assignmentReasonSortedByCreatedAt.length > 0 ? () => setIsOpenRoutingTraceSheet(true) : undefined } /> {isBookingFromRoutingForm && ( )} @@ -606,7 +601,7 @@ const BookingItemBadges = ({ )} - {isRejected && !isRescheduled && booking.assignmentReason.length === 0 && ( + {isRejected && !isRescheduled && booking.assignmentReasonSortedByCreatedAt.length === 0 && ( {t("rejected")} @@ -616,9 +611,9 @@ const BookingItemBadges = ({ {booking.eventType.team.name} )} - {booking?.assignmentReason.length > 0 && ( + {booking?.assignmentReasonSortedByCreatedAt.length > 0 && ( )} diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 9fa6cf7603..a9e029aea7 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -458,15 +458,10 @@ export function BookingActionsDropdown({ )} - {booking.assignmentReason.length > 0 && ( + {booking.assignmentReasonSortedByCreatedAt.length > 0 && ( = {}): Booki location: "integrations:daily", payment: [], seatsReferences: [], - assignmentReason: [], + assignmentReasonSortedByCreatedAt: [], metadata: null, routedFromRoutingFormReponse: null, listingStatus: "upcoming", diff --git a/apps/web/components/dialog/WrongAssignmentDialog.tsx b/apps/web/components/dialog/WrongAssignmentDialog.tsx index 4a54339dfe..8febd4f7bf 100644 --- a/apps/web/components/dialog/WrongAssignmentDialog.tsx +++ b/apps/web/components/dialog/WrongAssignmentDialog.tsx @@ -1,8 +1,10 @@ import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AssignmentReasonEnum } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import { Alert } from "@calcom/ui/components/alert"; +import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; import { Label, Select, TextArea } from "@calcom/ui/components/form"; @@ -12,15 +14,32 @@ import type { Dispatch, SetStateAction } from "react"; import type { Control, ControllerRenderProps } from "react-hook-form"; import { Controller, useForm } from "react-hook-form"; +import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; + +interface BookingData { + uid: string; + eventType?: { + team?: { + id: number; + } | null; + } | null; + user?: { + email: string; + name: string | null; + } | null; + assignmentReasonSortedByCreatedAt: Array<{ + reasonString: string | null; + reasonEnum: AssignmentReasonEnum | null; + }>; + attendees: Array<{ + email: string; + }>; +} + interface IWrongAssignmentDialog { isOpenDialog: boolean; setIsOpenDialog: Dispatch>; - bookingUid: string; - routingReason: string | null; - guestEmail: string; - hostEmail: string; - hostName: string | null; - teamId: number | null; + booking: BookingData; } interface FormValues { @@ -36,6 +55,7 @@ interface TeamMemberOption { interface RoutingInfoSectionProps { routingReason: string | null; + routingReasonEnum: AssignmentReasonEnum | null; noRoutingReasonText: string; routingReasonLabel: string; guestEmail: string; @@ -48,8 +68,10 @@ interface RoutingInfoSectionProps { } function RoutingInfoSection(props: RoutingInfoSectionProps): JSX.Element { + const { t } = useLocale(); const { routingReason, + routingReasonEnum, noRoutingReasonText, routingReasonLabel, guestEmail, @@ -79,9 +101,14 @@ function RoutingInfoSection(props: RoutingInfoSectionProps): JSX.Element {
-

- {routingReason || noRoutingReasonText} -

+
+ {routingReasonEnum && ( + + {t(assignmentReasonBadgeTitleMap(routingReasonEnum))} + + )} + {routingReason || noRoutingReasonText} +
@@ -212,16 +239,15 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen const { t } = useLocale(); const utils = trpc.useUtils(); const { copyToClipboard, isCopied } = useCopy(); - const { - isOpenDialog, - setIsOpenDialog, - bookingUid, - routingReason, - guestEmail, - hostEmail, - hostName, - teamId, - } = props; + const { isOpenDialog, setIsOpenDialog, booking } = props; + + const bookingUid = booking.uid; + const teamId = booking.eventType?.team?.id ?? null; + const routingReason = booking.assignmentReasonSortedByCreatedAt[0]?.reasonString ?? null; + const routingReasonEnum = booking.assignmentReasonSortedByCreatedAt[0]?.reasonEnum ?? null; + const guestEmail = booking.attendees[0]?.email ?? ""; + const hostEmail = booking.user?.email ?? ""; + const hostName = booking.user?.name ?? null; const { control, @@ -286,8 +312,9 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen [question, answer] as [string, unknown]) : []; - const reason = booking.assignmentReason?.[0]; + const reason = booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1]; const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum); return ( @@ -597,12 +597,12 @@ function RecurringInfoSection({ function AssignmentReasonSection({ booking }: { booking: BookingOutput }) { const { t } = useLocale(); - if (!booking.assignmentReason || booking.assignmentReason.length === 0) { + if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) { return null; } - // we fetch only one assignment reason. - const reason = booking.assignmentReason[0]; + const reason = + booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1]; if (!reason.reasonString) { return null; } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6b01c57918..fc59e69270 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -848,6 +848,7 @@ "wrong_assignment_reported": "Wrong assignment reported successfully", "wrong_assignment_already_reported": "A wrong assignment report has already been submitted for this booking.", "routing_reason": "Routing Reason", + "first_assignment_reason": "First Assignment Reason", "no_routing_reason": "No routing reason available", "who_booked_it": "Who booked it?", "who_received_it": "Who received it?", diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index fc2780312f..cbf6a66896 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2123,54 +2123,6 @@ export class BookingRepository implements IBookingRepository { }); } - async findByUidIncludeEventTypeAndTeamAndAssignmentReason({ bookingUid }: { bookingUid: string }) { - return await this.prismaClient.booking.findUnique({ - where: { - uid: bookingUid, - }, - select: { - id: true, - uid: true, - title: true, - startTime: true, - endTime: true, - status: true, - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - attendees: { - select: { - email: true, - name: true, - }, - }, - eventType: { - select: { - id: true, - title: true, - slug: true, - teamId: true, - }, - }, - assignmentReason: { - select: { - reasonString: true, - reasonEnum: true, - }, - }, - routedFromRoutingFormReponse: { - select: { - formId: true, - }, - }, - }, - }); - } - async updateRecordedStatus({ bookingUid, isRecorded, @@ -2183,4 +2135,58 @@ export class BookingRepository implements IBookingRepository { data: { isRecorded }, }); } + + async findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ bookingUid }: { bookingUid: string }) { + return await this.prismaClient.booking.findUnique({ + where: { uid: bookingUid }, + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + status: true, + userId: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + eventType: { + select: { + id: true, + title: true, + slug: true, + team: { + select: { + id: true, + parentId: true, + }, + }, + }, + }, + attendees: { + select: { + email: true, + }, + }, + assignmentReason: { + select: { + reasonString: true, + }, + take: 1, + orderBy: { + createdAt: "asc" as const, + }, + }, + routedFromRoutingFormReponse: { + select: { + formId: true, + }, + }, + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 4c64b466bb..fe51492b3c 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -631,9 +631,8 @@ export async function getBookings({ .selectFrom("AssignmentReason") .selectAll() .whereRef("AssignmentReason.bookingId", "=", "Booking.id") - .orderBy("AssignmentReason.createdAt", "desc") - .limit(1) - ).as("assignmentReason"), + .orderBy("AssignmentReason.createdAt", "asc") + ).as("assignmentReasonSortedByCreatedAt"), jsonObjectFrom( eb .selectFrom("BookingReport") diff --git a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts index c8df4b6e12..6e358c4cab 100644 --- a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts +++ b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts @@ -1,6 +1,3 @@ -import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; -import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository"; -import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; @@ -8,12 +5,39 @@ import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { reportWrongAssignmentHandler } from "./reportWrongAssignment.handler"; -vi.mock("@calcom/features/bookings/repositories/BookingRepository"); -vi.mock("@calcom/features/bookings/repositories/WrongAssignmentReportRepository"); -vi.mock("@calcom/features/bookings/services/BookingAccessService"); +const mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason = vi.fn(); +const mockDoesUserIdHaveAccessToBooking = vi.fn(); +const mockExistsByBookingUid = vi.fn(); +const mockCreateReport = vi.fn(); + +vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => { + return { + BookingRepository: class MockBookingRepository { + findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason = + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason; + }, + }; +}); +vi.mock("@calcom/features/bookings/repositories/WrongAssignmentReportRepository", () => { + return { + WrongAssignmentReportRepository: class MockWrongAssignmentReportRepository { + existsByBookingUid = mockExistsByBookingUid; + createReport = mockCreateReport; + }, + }; +}); +vi.mock("@calcom/features/bookings/services/BookingAccessService", () => { + return { + BookingAccessService: class MockBookingAccessService { + doesUserIdHaveAccessToBooking = mockDoesUserIdHaveAccessToBooking; + }, + }; +}); vi.mock("@calcom/features/webhooks/lib/getWebhooks"); vi.mock("@calcom/features/webhooks/lib/sendPayload"); -vi.mock("@calcom/prisma", () => ({ default: {} })); +vi.mock("@calcom/prisma", () => ({ + default: {}, +})); vi.mock("@calcom/lib/server/i18n", () => ({ getTranslation: vi.fn().mockResolvedValue((key: string) => { const translations: Record = { @@ -39,6 +63,11 @@ describe("reportWrongAssignmentHandler", () => { name: "Reporter User", }; + const mockInput = { + bookingUid: "test-booking-uid", + additionalNotes: "This booking was assigned to me incorrectly", + }; + const mockBooking = { id: 100, uid: "test-booking-uid", @@ -46,74 +75,51 @@ describe("reportWrongAssignmentHandler", () => { startTime: new Date("2025-12-01T10:00:00Z"), endTime: new Date("2025-12-01T11:00:00Z"), status: BookingStatus.ACCEPTED, + userId: 2, user: { id: 2, email: "host@example.com", name: "Host User", }, - attendees: [{ email: "guest@example.com" }], eventType: { id: 10, title: "Test Event", slug: "test-event", - teamId: 5, + team: { + id: 5, + parentId: 1, + }, }, + attendees: [{ email: "guest@example.com" }], assignmentReason: [{ reasonString: "Matched by round-robin" }], routedFromRoutingFormReponse: null, }; - const mockBookingRepo = { - findByUidIncludeEventTypeAndTeamAndAssignmentReason: vi.fn(), - }; - - const mockBookingAccessService = { - doesUserIdHaveAccessToBooking: vi.fn(), - }; - - const mockWrongAssignmentReportRepo = { - existsByBookingUid: vi.fn().mockResolvedValue(false), - createReport: vi.fn().mockResolvedValue({ id: "report-uuid-123" }), - }; - beforeEach(() => { vi.clearAllMocks(); - mockWrongAssignmentReportRepo.existsByBookingUid.mockResolvedValue(false); - mockWrongAssignmentReportRepo.createReport.mockResolvedValue({ id: "report-uuid-123" }); - - vi.mocked(BookingRepository).mockImplementation(function () { - return mockBookingRepo; - }); - vi.mocked(WrongAssignmentReportRepository).mockImplementation(function () { - return mockWrongAssignmentReportRepo; - }); - vi.mocked(BookingAccessService).mockImplementation(function () { - return mockBookingAccessService; - }); + mockExistsByBookingUid.mockResolvedValue(false); + mockCreateReport.mockResolvedValue({ id: "report-uuid-123" }); vi.mocked(getWebhooks).mockResolvedValue([]); vi.mocked(sendGenericWebhookPayload).mockResolvedValue({ ok: true, status: 200 }); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); }); describe("access control", () => { - it("should throw FORBIDDEN when user doesn't have access to booking", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(false); + it("should throw FORBIDDEN when BookingAccessService denies access", async () => { + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(false); await expect( reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "This booking was assigned to me incorrectly", - }, + input: mockInput, }) ).rejects.toThrow(TRPCError); await expect( reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "This booking was assigned to me incorrectly", - }, + input: mockInput, }) ).rejects.toMatchObject({ code: "FORBIDDEN", @@ -122,16 +128,20 @@ describe("reportWrongAssignmentHandler", () => { }); it("should throw NOT_FOUND when booking doesn't exist", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(null); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(null); await expect( reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "This booking was assigned to me incorrectly", - }, + input: mockInput, + }) + ).rejects.toThrow(TRPCError); + + await expect( + reportWrongAssignmentHandler({ + ctx: { user: mockUser }, + input: mockInput, }) ).rejects.toMatchObject({ code: "NOT_FOUND", @@ -139,10 +149,29 @@ describe("reportWrongAssignmentHandler", () => { }); }); + it("should allow access when BookingAccessService grants access", async () => { + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); + + const result = await reportWrongAssignmentHandler({ + ctx: { user: mockUser }, + input: mockInput, + }); + + expect(result.success).toBe(true); + expect(mockDoesUserIdHaveAccessToBooking).toHaveBeenCalledWith({ + userId: mockUser.id, + bookingUid: mockInput.bookingUid, + }); + }); + it("should throw BAD_REQUEST when a report already exists for the booking", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); - mockWrongAssignmentReportRepo.existsByBookingUid.mockResolvedValue(true); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); + mockExistsByBookingUid.mockResolvedValue(true); await expect( reportWrongAssignmentHandler({ @@ -157,19 +186,21 @@ describe("reportWrongAssignmentHandler", () => { message: "A wrong assignment report has already been submitted for this booking", }); - expect(mockWrongAssignmentReportRepo.createReport).not.toHaveBeenCalled(); + expect(mockCreateReport).not.toHaveBeenCalled(); }); }); describe("successful report", () => { it("should successfully report with all fields", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, input: { - bookingUid: "test-booking-uid", + ...mockInput, correctAssignee: "correct@example.com", additionalNotes: "This booking should have gone to the sales team", }, @@ -180,13 +211,15 @@ describe("reportWrongAssignmentHandler", () => { }); it("should successfully report with only required fields (no correctAssignee)", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, input: { - bookingUid: "test-booking-uid", + ...mockInput, additionalNotes: "Wrong person received this booking", }, }); @@ -198,8 +231,10 @@ describe("reportWrongAssignmentHandler", () => { describe("webhook integration", () => { it("should send webhook with correct payload structure", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); vi.mocked(getWebhooks).mockResolvedValue([ { id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" }, ] as never); @@ -207,15 +242,16 @@ describe("reportWrongAssignmentHandler", () => { await reportWrongAssignmentHandler({ ctx: { user: mockUser }, input: { - bookingUid: "test-booking-uid", + ...mockInput, correctAssignee: "correct@example.com", additionalNotes: "Wrong assignment notes", }, }); expect(getWebhooks).toHaveBeenCalledWith({ - userId: mockBooking.user.id, - teamId: mockBooking.eventType.teamId, + userId: mockUser.id, + teamId: mockBooking.eventType.team.id, + orgId: mockBooking.eventType.team.parentId, triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, }); @@ -233,10 +269,10 @@ describe("reportWrongAssignmentHandler", () => { id: mockUser.id, email: mockUser.email, }), - routingReason: "Matched by round-robin", - guest: "guest@example.com", + firstAssignmentReason: mockBooking.assignmentReason[0].reasonString, + guest: mockBooking.attendees[0].email, host: expect.objectContaining({ - email: "host@example.com", + email: mockBooking.user.email, }), correctAssignee: "correct@example.com", additionalNotes: "Wrong assignment notes", @@ -247,8 +283,10 @@ describe("reportWrongAssignmentHandler", () => { }); it("should handle webhook failures gracefully", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); vi.mocked(getWebhooks).mockResolvedValue([ { id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" }, ] as never); @@ -256,10 +294,7 @@ describe("reportWrongAssignmentHandler", () => { const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(result.success).toBe(true); @@ -267,16 +302,15 @@ describe("reportWrongAssignmentHandler", () => { }); it("should not fail when getWebhooks throws", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ + ...mockBooking, + userId: mockUser.id, + }); vi.mocked(getWebhooks).mockRejectedValue(new Error("Database error")); const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(result.success).toBe(true); @@ -285,8 +319,8 @@ describe("reportWrongAssignmentHandler", () => { describe("database persistence", () => { it("should persist report to database with all fields", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); await reportWrongAssignmentHandler({ ctx: { user: mockUser }, @@ -297,19 +331,19 @@ describe("reportWrongAssignmentHandler", () => { }, }); - expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalledWith({ + expect(mockCreateReport).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", reportedById: mockUser.id, correctAssignee: "correct@example.com", additionalNotes: "This booking should have gone to the sales team", - teamId: mockBooking.eventType.teamId, + teamId: mockBooking.eventType.team.id, routingFormId: null, }); }); it("should persist report with null optional fields", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({ + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ ...mockBooking, eventType: null, }); @@ -322,7 +356,7 @@ describe("reportWrongAssignmentHandler", () => { }, }); - expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalledWith({ + expect(mockCreateReport).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", reportedById: mockUser.id, correctAssignee: null, @@ -333,8 +367,8 @@ describe("reportWrongAssignmentHandler", () => { }); it("should return reportId in response", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, @@ -348,9 +382,9 @@ describe("reportWrongAssignmentHandler", () => { }); it("should throw if database write fails", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); - mockWrongAssignmentReportRepo.createReport.mockRejectedValue(new Error("Database error")); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); + mockCreateReport.mockRejectedValue(new Error("Database error")); await expect( reportWrongAssignmentHandler({ @@ -364,8 +398,8 @@ describe("reportWrongAssignmentHandler", () => { }); it("should persist report even when webhooks fail", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking); + mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true); + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); vi.mocked(getWebhooks).mockRejectedValue(new Error("Webhook service down")); const result = await reportWrongAssignmentHandler({ @@ -376,16 +410,16 @@ describe("reportWrongAssignmentHandler", () => { }, }); - expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalled(); + expect(mockCreateReport).toHaveBeenCalled(); expect(result.success).toBe(true); }); }); describe("edge cases", () => { it("should handle booking without assignmentReason", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({ + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ ...mockBooking, + userId: mockUser.id, assignmentReason: [], }); vi.mocked(getWebhooks).mockResolvedValue([ @@ -394,17 +428,14 @@ describe("reportWrongAssignmentHandler", () => { await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(sendGenericWebhookPayload).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ report: expect.objectContaining({ - routingReason: null, + firstAssignmentReason: null, }), }), }) @@ -412,18 +443,16 @@ describe("reportWrongAssignmentHandler", () => { }); it("should handle booking without user", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({ + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ ...mockBooking, + userId: null, user: null, + attendees: [{ email: mockUser.email }], }); const result = await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(result.success).toBe(true); @@ -435,9 +464,9 @@ describe("reportWrongAssignmentHandler", () => { }); it("should handle booking without eventType", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({ + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ ...mockBooking, + userId: mockUser.id, eventType: null, }); vi.mocked(getWebhooks).mockResolvedValue([ @@ -446,15 +475,13 @@ describe("reportWrongAssignmentHandler", () => { await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(getWebhooks).toHaveBeenCalledWith( expect.objectContaining({ teamId: null, + orgId: null, }) ); expect(sendGenericWebhookPayload).toHaveBeenCalledWith( @@ -469,9 +496,9 @@ describe("reportWrongAssignmentHandler", () => { }); it("should handle booking without attendees", async () => { - mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true); - mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({ + mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue({ ...mockBooking, + userId: mockUser.id, attendees: [], }); vi.mocked(getWebhooks).mockResolvedValue([ @@ -480,17 +507,14 @@ describe("reportWrongAssignmentHandler", () => { await reportWrongAssignmentHandler({ ctx: { user: mockUser }, - input: { - bookingUid: "test-booking-uid", - additionalNotes: "Notes", - }, + input: mockInput, }); expect(sendGenericWebhookPayload).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ report: expect.objectContaining({ - guest: "", + guest: null, }), }), }) diff --git a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts index 26eef6a62b..885df86fc8 100644 --- a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts @@ -28,7 +28,7 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs const { bookingUid, correctAssignee, additionalNotes } = input; const t = await getTranslation(user.locale ?? "en", "common"); - const bookingRepo = new BookingRepository(prisma); + const bookingRepository = new BookingRepository(prisma); const wrongAssignmentReportRepo = new WrongAssignmentReportRepository(prisma); const bookingAccessService = new BookingAccessService(prisma); @@ -41,19 +41,16 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this booking" }); } - const booking = await bookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason({ bookingUid }); + const booking = await bookingRepository.findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ + bookingUid, + }); if (!booking) { throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" }); } - const teamId = booking.eventType?.teamId || null; - const bookingUserId = booking.user?.id || null; - - const assignmentReason = booking.assignmentReason?.[0]; - const guestEmail = booking.attendees[0]?.email || ""; - const hostEmail = booking.user?.email || ""; - const hostName = booking.user?.name || null; + const teamId = booking.eventType?.team?.id ?? null; + const orgId = booking.eventType?.team?.parentId ?? null; const routingFormId = booking.routedFromRoutingFormReponse?.formId || null; const alreadyReported = await wrongAssignmentReportRepo.existsByBookingUid(booking.uid); @@ -98,7 +95,7 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs id: booking.eventType.id, title: booking.eventType.title, slug: booking.eventType.slug, - teamId: booking.eventType.teamId, + teamId: teamId, } : null, }, @@ -108,11 +105,11 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs email: user.email, name: user.name, }, - routingReason: assignmentReason?.reasonString || null, - guest: guestEmail, + firstAssignmentReason: booking.assignmentReason[0]?.reasonString ?? null, + guest: booking.attendees[0]?.email ?? null, host: { - email: hostEmail, - name: hostName, + email: booking.user?.email ?? null, + name: booking.user?.name ?? null, }, correctAssignee: correctAssignee || null, additionalNotes: additionalNotes || null, @@ -121,8 +118,9 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs try { const webhooks = await getWebhooks({ - userId: bookingUserId, + userId: booking.userId, teamId, + orgId, triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, }); @@ -141,9 +139,9 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs await Promise.allSettled(webhookPromises); - log.info(`Wrong assignment report sent for booking ${bookingUid}`, { + log.info(`Wrong assignment report sent for booking ${booking.uid}`, { teamId, - userId: bookingUserId, + userId: booking.userId, webhookCount: webhooks.length, }); } catch (error) { diff --git a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.schema.ts b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.schema.ts index 70caaa6dc2..fa426af5c3 100644 --- a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const ZReportWrongAssignmentInputSchema = z.object({ bookingUid: z.string(), - correctAssignee: z.string().email().optional(), + correctAssignee: z.union([z.string().email(), z.literal("").transform(() => undefined)]).optional(), additionalNotes: z.string().min(1), });