From 7cfb0530263e4e135448e123ac50f5530d718c97 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:02:33 -0500 Subject: [PATCH] feat: add wrong routing tab under Insights for reviewing assignment reports (#27423) * Add DB table for wrong assignment reports * When report is submitted write to the db * Prevent duplicate reportings * test: add migration and tests for WrongAssignmentReport table Co-Authored-By: joe@cal.com * fix: add unique constraint on bookingUid and booking access check for hasWrongAssignmentReport - Add @unique constraint on bookingUid in WrongAssignmentReport model to prevent duplicate reports at DB level - Add booking ownership check using BookingAccessService in hasWrongAssignmentReport endpoint - Refactor hasWrongAssignmentReport into separate handler and schema files Addresses Cubic AI review feedback on PR #27405 Co-Authored-By: unknown <> * feat: add routingFormId to WrongAssignmentReport and fix Select clearing - Add routingFormId field to WrongAssignmentReport model in schema.prisma - Add relation to App_RoutingForms_Form with SetNull on delete - Update WrongAssignmentReportRepository.createReport to accept routingFormId - Update BookingRepository.findByUidIncludeEventTypeAndTeamAndAssignmentReason to include routedFromRoutingFormReponse - Extract routingFormId from booking in reportWrongAssignment handler - Fix Select clearing issue: handle null case when user clears team member selection - Update tests to include routingFormId field Co-Authored-By: joe@cal.com * chore: add migration for routingFormId in WrongAssignmentReport Co-Authored-By: joe@cal.com * feat: add wrong assignment reports dashboard under routing tab - Add reviewedById and reviewedAt fields to WrongAssignmentReport model - Add repository methods for listing reports by status and updating status - Create tRPC endpoints for fetching reports and updating status - Create dashboard UI with pending/reviewed tabs showing routing form name - Add translation keys for dashboard UI - Integrate dashboard into routing insights page Co-Authored-By: joe@cal.com * fix: move hooks before early return and fix indentation Co-Authored-By: joe@cal.com * fix: address Udit's review comments - hasWrongAssignmentReport: throw UNAUTHORIZED error instead of returning false - reportWrongAssignment: add try-catch for Prisma P2002 unique constraint error - WrongAssignmentReport: add Team relation to teamId field - WrongAssignmentReportRepository: use findUnique instead of findFirst - reportWrongAssignment: use i18n for error messages Co-Authored-By: joe@cal.com * fix: revert hasWrongAssignmentReport to return false when user lacks access Per PR checklist, hasWrongAssignmentReport should return { hasReport: false } when user lacks access to booking, not throw an error. This allows the UI to gracefully treat 'no access' as 'no report exists'. Co-Authored-By: joe@cal.com * test: update mocks for findUnique and i18n in unit tests Co-Authored-By: joe@cal.com * fix: throw UNAUTHORIZED in hasWrongAssignmentReport and squash migrations Co-Authored-By: joe@cal.com * feat: move wrong assignment reports to its own tab under Insights Co-Authored-By: joe@cal.com * Use data table for wrong reports * Add option to view routing trace * feat: add view routing form submission action to wrong assignment reports Co-Authored-By: joe@cal.com * feat: add RoutingFormResponseSheet component for viewing form submissions - Create slide-out sheet to display routing form responses - Map option IDs to display labels for select/multiselect fields - Handle both legacy and modern option formats - Add i18n strings: form_submission, no_responses_found Co-Authored-By: Claude Opus 4.5 * feat: improve wrong assignment reports UX - Integrate RoutingFormResponseSheet as slide-out panel instead of new tab - Fix dropdown padding by using StartIcon prop instead of manual Icon - Allow direct status changes for reviewed reports (no need to reopen first) - Remove unused Icon import Co-Authored-By: Claude Opus 4.5 * fix: change User relation onDelete from Cascade to SetNull in WrongAssignmentReport Address Hariom's review feedback: - Changed reportedById from Int to Int? (nullable) - Changed reportedBy relation from onDelete: Cascade to onDelete: SetNull - Updated migration SQL to reflect these changes This preserves wrong assignment reports even when the reporting user is deleted, as the data is still useful for analysis. Co-Authored-By: joe@cal.com * feat: add missing i18n strings for wrong assignment reports dashboard Co-Authored-By: joe@cal.com * refactor: move form response display value resolution server-side Replace client-side option ID to label resolution in RoutingFormResponseSheet with a new lean tRPC endpoint (getFormResponseDisplay) that resolves values server-side using the existing getHumanReadableFieldResponseValue utility. This enforces DTO boundaries by returning a clean pre-resolved payload instead of leaking internal option format details to the client. Co-Authored-By: Claude Opus 4.6 * feat: add report wrong assignment button to routing trace sheet Wire up the WrongAssignmentDialog from the routing trace sheet header so users can flag wrong assignments directly while viewing the trace. The report button is disabled with a tooltip when a report already exists. Co-Authored-By: Claude Opus 4.6 * refactor: reuse existing WrongAssignmentDialog from parent Replace the duplicate WrongAssignmentDialog in RoutingTraceSheet with a callback to the existing instance in BookingActionsDropdown. This reduces the prop surface from a 6-field reportContext object to an onReport callback and hasExistingReport boolean. Co-Authored-By: Claude Opus 4.6 * refactor: use repository instead of direct Prisma in getFormResponseDisplay Replace direct Prisma query with PrismaRoutingFormResponseRepository's findByIdIncludeForm method. Extend the method to also select form name, description, userId, and teamId needed for display and auth checks. Co-Authored-By: Claude Opus 4.6 * refactor: replace direct Prisma calls with repository methods Use MembershipRepository.hasMembership() for auth checks and TeamRepository.findAllByParentId() for child team queries instead of direct Prisma calls. Replace direct user query with UserRepository.getTimeZoneAndDefaultScheduleId(). Remove unused seed script. Co-Authored-By: Claude Opus 4.6 * refactor: use PBAC services for wrong assignment report auth Replace manual MembershipRepository.hasMembership() checks with PBAC-aware permission checking. getWrongAssignmentReports uses createTeamPbacProcedure middleware since teamId is in input. updateWrongAssignmentReportStatus uses PermissionCheckService directly since teamId is discovered from the report entity. Co-Authored-By: Claude Opus 4.6 * fix: resolve type errors in RoutingFormResponseSheet and wrong-routing view Co-Authored-By: joe@cal.com * feat: add seed script for wrong assignment reports test data Co-Authored-By: hariom@cal.com * Revert "feat: add seed script for wrong assignment reports test data" This reverts commit 0bd60e9661858a59aab1573d14d57d81733b7991. * Only update reviewed fields when not pending Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor: wrap handleStatusChange in useCallback to fix useMemo recalculation Co-Authored-By: joe@cal.com * Fix routing sheet UI * fix: use appropriate error message in getFormResponseDisplay handler Co-Authored-By: joe@cal.com * refactor: extract WrongAssignmentReportService from tRPC handlers Move business logic (booking lookup, duplicate check, report creation, webhook dispatch, org-level team resolution) into a dedicated service in packages/features. Handlers become thin controllers that only handle auth checks and delegate to the service. Co-Authored-By: Claude Opus 4.6 * fix: restrict report status updates to admin/owner roles Remove MembershipRole.MEMBER from fallbackRoles in updateWrongAssignmentReportStatus permission check. Updating report status is an administrative action that should be limited to team admins and owners. Co-Authored-By: Claude Opus 4.6 * fix: localize hard-coded success message in WrongAssignmentReportService Co-Authored-By: joe@cal.com * fix: rename reviewed tab to handled and add missing translations Rename the "Reviewed" tab to "Handled" since it groups three distinct statuses (Reviewed, Resolved, Dismissed). Also add missing translation keys for "resolved" and "dismissed" status badges. Co-Authored-By: Claude Opus 4.6 * merge: resolve conflicts with main branch Merge main into devin/1769747741-wrong-assignment-dashboard, resolving conflicts in: - BookingActionsDropdown.tsx: use main's booking prop with PR's fragment structure - reportWrongAssignment.handler.ts: keep PR's service-based approach - reportWrongAssignment.handler.test.ts: use main's class-based mocks with PR's additions - WrongAssignmentReportService.ts: align with main's repo method and field names Co-Authored-By: joe@cal.com * Add guard when accessing assignmentReasonSortedByCreatedAt Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: address Cubic AI review feedback - select projection and useLocale refactor Co-Authored-By: joe@cal.com * fix: remove stale TRPCError assertion in test (service throws ErrorWithCode) Co-Authored-By: joe@cal.com * refactor: remove unused findByTeamIdAndStatus and findByTeamIdAndStatuses methods Co-Authored-By: alex@cal.com * perf: add composite index on WrongAssignmentReport and narrow findByIdIncludeForm select Co-Authored-By: alex@cal.com * perf: use lightweight findTeamIdById in update-status handler instead of findById Co-Authored-By: alex@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: hariom@cal.com Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: alex@cal.com --- .../routing-forms/[...pages]/RouteBuilder.tsx | 6 +- .../insights/wrong-routing/page.tsx | 26 ++ .../booking/RoutingFormResponseSheet.tsx | 72 +++++ .../components/booking/RoutingTraceSheet.tsx | 46 ++- .../actions/BookingActionsDropdown.tsx | 49 ++-- .../components/BookingDetailsSheet.tsx | 25 +- .../WrongAssignmentReportsDashboard.tsx | 274 ++++++++++++++++++ .../insights/components/routing/index.ts | 1 + .../hooks/useWrongAssignmentColumns.tsx | 178 ++++++++++++ .../useWrongAssignmentFacetedUniqueValues.ts | 61 ++++ .../views/insights-wrong-routing-view.tsx | 17 ++ .../modules/shell/navigation/Navigation.tsx | 5 + apps/web/public/static/locales/en/common.json | 34 ++- .../repository/PrismaAuditActorRepository.ts | 2 + .../WrongAssignmentReportRepository.test.ts | 4 +- .../WrongAssignmentReportRepository.ts | 127 ++++++++ .../services/WrongAssignmentReportService.ts | 198 +++++++++++++ .../PrismaRoutingFormResponseRepository.ts | 7 +- ...RoutingFormResponseRepository.interface.ts | 5 +- .../migration.sql | 9 + .../migration.sql | 1 + packages/prisma/schema.prisma | 6 + .../routers/apps/routing-forms/_router.ts | 9 + .../getFormResponseDisplay.handler.ts | 89 ++++++ .../routers/viewer/bookings/_router.tsx | 38 ++- .../getWrongAssignmentReports.handler.ts | 30 ++ .../getWrongAssignmentReports.schema.ts | 24 ++ .../reportWrongAssignment.handler.test.ts | 24 +- .../bookings/reportWrongAssignment.handler.ts | 142 +-------- ...dateWrongAssignmentReportStatus.handler.ts | 68 +++++ ...pdateWrongAssignmentReportStatus.schema.ts | 12 + 31 files changed, 1400 insertions(+), 189 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx create mode 100644 apps/web/components/booking/RoutingFormResponseSheet.tsx create mode 100644 apps/web/modules/insights/components/routing/WrongAssignmentReportsDashboard.tsx create mode 100644 apps/web/modules/insights/hooks/useWrongAssignmentColumns.tsx create mode 100644 apps/web/modules/insights/hooks/useWrongAssignmentFacetedUniqueValues.ts create mode 100644 apps/web/modules/insights/views/insights-wrong-routing-view.tsx create mode 100644 packages/features/bookings/services/WrongAssignmentReportService.ts create mode 100644 packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql create mode 100644 packages/prisma/migrations/20260211234000_add_composite_index_wrong_assignment_report/migration.sql create mode 100644 packages/trpc/server/routers/apps/routing-forms/getFormResponseDisplay.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.schema.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.schema.ts diff --git a/apps/web/app/(use-page-wrapper)/apps/routing-forms/[...pages]/RouteBuilder.tsx b/apps/web/app/(use-page-wrapper)/apps/routing-forms/[...pages]/RouteBuilder.tsx index 399eb997a3..fc444f9153 100644 --- a/apps/web/app/(use-page-wrapper)/apps/routing-forms/[...pages]/RouteBuilder.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/routing-forms/[...pages]/RouteBuilder.tsx @@ -359,7 +359,6 @@ type RouteActionSelectorProps = { onCustomEventTypeSlugChange?: (slug: string) => void; eventTypePrefix?: string; fieldIdentifiers?: string[]; - t: (key: string, options?: Record) => string; }; const RouteActionSelector = ({ @@ -375,8 +374,8 @@ const RouteActionSelector = ({ onCustomEventTypeSlugChange, eventTypePrefix = "", fieldIdentifiers = [], - t, }: RouteActionSelectorProps) => { + const { t } = useLocale(); return (
@@ -803,7 +802,6 @@ const Route = ({ onCustomEventTypeSlugChange={setCustomFallbackEventTypeSlug} eventTypePrefix={eventTypePrefix} fieldIdentifiers={fieldIdentifiers} - t={t} />
{isOrganization && ( @@ -899,7 +897,6 @@ const Route = ({ onCustomEventTypeSlugChange={setCustomEventTypeSlug} eventTypePrefix={eventTypePrefix} fieldIdentifiers={fieldIdentifiers} - t={t} /> ) : (
@@ -938,7 +935,6 @@ const Route = ({ onCustomEventTypeSlugChange={setCustomEventTypeSlug} eventTypePrefix={eventTypePrefix} fieldIdentifiers={fieldIdentifiers} - t={t} />
{attributesQueryBuilder} diff --git a/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx b/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx new file mode 100644 index 0000000000..e06188e41e --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/wrong-routing/page.tsx @@ -0,0 +1,26 @@ +import { _generateMetadata } from "app/_utils"; + +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import prisma from "@calcom/prisma"; + +import InsightsWrongRoutingPage from "~/insights/views/insights-wrong-routing-view"; + +import { checkInsightsPagePermission } from "../checkInsightsPagePermission"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("wrong_routing"), + (t) => t("wrong_routing_description"), + undefined, + undefined, + "/insights/wrong-routing" + ); + +export default async function Page() { + const session = await checkInsightsPagePermission(); + + const userRepository = new UserRepository(prisma); + const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session?.user.id ?? -1 }); + + return ; +} diff --git a/apps/web/components/booking/RoutingFormResponseSheet.tsx b/apps/web/components/booking/RoutingFormResponseSheet.tsx new file mode 100644 index 0000000000..43c307f198 --- /dev/null +++ b/apps/web/components/booking/RoutingFormResponseSheet.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetBody } from "@calcom/ui/components/sheet"; + +interface RoutingFormResponseSheetProps { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + formResponseId: number; +} + +export function RoutingFormResponseSheet({ + isOpen, + setIsOpen, + formResponseId, +}: RoutingFormResponseSheetProps) { + const { t } = useLocale(); + + const { data, isLoading } = trpc.viewer.appRoutingForms.getFormResponseDisplay.useQuery( + { formResponseId }, + { + enabled: isOpen && !!formResponseId, + staleTime: 10 * 60 * 1000, + } + ); + + return ( + + + + {t("form_submission")} + + + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ )} + {!isLoading && (!data?.responses || data.responses.length === 0) && ( +

{t("no_responses_found")}

+ )} + {!isLoading && data?.responses && data.responses.length > 0 && ( +
+ {data.form?.name && ( +
+

{data.form.name}

+ {data.form.description && ( +

{data.form.description}

+ )} +
+ )} + {data.responses.map((item) => ( +
+ +

{item.displayValue}

+
+ ))} +
+ )} + + + + ); +} diff --git a/apps/web/components/booking/RoutingTraceSheet.tsx b/apps/web/components/booking/RoutingTraceSheet.tsx index c23762d95f..94d29f8694 100644 --- a/apps/web/components/booking/RoutingTraceSheet.tsx +++ b/apps/web/components/booking/RoutingTraceSheet.tsx @@ -5,16 +5,34 @@ import { DomainIcon } from "@calcom/features/routing-trace/components/DomainIcon import { getDomainLabel } from "@calcom/features/routing-trace/presenters/getDomainLabel"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetBody } from "@calcom/ui/components/sheet"; +import { Button } from "@calcom/ui/components/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, +} from "@calcom/ui/components/sheet"; + interface RoutingTraceSheetProps { isOpen: boolean; setIsOpen: (open: boolean) => void; bookingUid: string; + /** Callback to open the wrong assignment report dialog. If not provided, report button is hidden */ + onReport?: () => void; + hasExistingReport?: boolean; } -export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTraceSheetProps) { +export function RoutingTraceSheet({ + isOpen, + setIsOpen, + bookingUid, + onReport, + hasExistingReport, +}: RoutingTraceSheetProps) { const { t } = useLocale(); + const showReportButton = !!onReport; const { data, isLoading } = trpc.viewer.bookings.getRoutingTrace.useQuery( { bookingUid }, @@ -27,8 +45,24 @@ export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTrac return ( - - {t("routing_trace")} + + {showReportButton ? ( +
+ {t("routing_trace")} +
+ +
+
+ ) : ( + {t("routing_trace")} + )}
{isLoading && ( @@ -54,7 +88,9 @@ export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTrac return (
{/* Timeline connector line */} - {!isLast &&
} + {!isLast && ( +
+ )} {/* Icon circle */}
diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index a9e029aea7..d6c16111d8 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -1,14 +1,9 @@ -import { useState } from "react"; -import type { z } from "zod"; - -import { MeetingSessionDetailsDialog } from "@calcom/web/modules/ee/video/components/MeetingSessionDetailsDialog"; -import ViewRecordingsDialog from "~/ee/video/components/ViewRecordingsDialog"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; -import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; +import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; import { Dropdown, DropdownItem, @@ -19,10 +14,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; -import { Tooltip } from "@calcom/ui/components/tooltip"; import type { ActionType } from "@calcom/ui/components/table"; import { showToast } from "@calcom/ui/components/toast"; - +import { Tooltip } from "@calcom/ui/components/tooltip"; +import { MeetingSessionDetailsDialog } from "@calcom/web/modules/ee/video/components/MeetingSessionDetailsDialog"; import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { CancelBookingDialog } from "@components/dialog/CancelBookingDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; @@ -33,20 +28,22 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import { WrongAssignmentDialog } from "@components/dialog/WrongAssignmentDialog"; -import { RoutingTraceSheet } from "../RoutingTraceSheet"; - +import { useState } from "react"; +import type { z } from "zod"; +import ViewRecordingsDialog from "~/ee/video/components/ViewRecordingsDialog"; import { useBookingConfirmation } from "../hooks/useBookingConfirmation"; +import { RoutingTraceSheet } from "../RoutingTraceSheet"; import type { BookingItemProps } from "../types"; import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider"; import { + type BookingActionContext, + getAfterEventActions, getCancelEventAction, getEditEventActions, - getAfterEventActions, + getPendingActions, getReportAction, shouldShowEditActions, shouldShowPendingActions, - getPendingActions, - type BookingActionContext, } from "./bookingActions"; interface BookingActionsDropdownProps { @@ -455,18 +452,20 @@ export function BookingActionsDropdown({ status={getBookingStatus()} /> {isBookingFromRoutingForm && ( - - )} - {booking.assignmentReasonSortedByCreatedAt.length > 0 && ( - + <> + + setIsOpenWrongAssignmentDialog(true)} + hasExistingReport={!!booking.report} + /> + )} {booking.paid && booking.payment[0] && ( ; interface BookingDetailsSheetProps { @@ -215,7 +213,8 @@ function BookingDetailsSheetInner({ .map(([question, answer]) => [question, answer] as [string, unknown]) : []; - const reason = booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1]; + const reason = + booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1]; const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum); return ( diff --git a/apps/web/modules/insights/components/routing/WrongAssignmentReportsDashboard.tsx b/apps/web/modules/insights/components/routing/WrongAssignmentReportsDashboard.tsx new file mode 100644 index 0000000000..02b3594a6f --- /dev/null +++ b/apps/web/modules/insights/components/routing/WrongAssignmentReportsDashboard.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useDataTable, useFilterValue, ZSingleSelectFilterValue } from "@calcom/features/data-table"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { showToast } from "@calcom/ui/components/toast"; +import { RoutingFormResponseSheet } from "@calcom/web/components/booking/RoutingFormResponseSheet"; +import { RoutingTraceSheet } from "@calcom/web/components/booking/RoutingTraceSheet"; +import { + DataTableFilters, + DataTableSkeleton, + DataTableWrapper, +} from "@calcom/web/modules/data-table/components"; +import { useInsightsOrgTeams } from "@calcom/web/modules/insights/hooks/useInsightsOrgTeams"; +import { + useWrongAssignmentColumns, + type WrongAssignmentReportRow, +} from "@calcom/web/modules/insights/hooks/useWrongAssignmentColumns"; +import { useWrongAssignmentFacetedUniqueValues } from "@calcom/web/modules/insights/hooks/useWrongAssignmentFacetedUniqueValues"; +import { createColumnHelper, getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; +import { useCallback, useMemo, useState } from "react"; +import { OrgTeamsFilter } from "../filters/OrgTeamsFilter"; + +type TabType = "pending" | "handled"; + +export function WrongAssignmentReportsDashboard() { + const { t } = useLocale(); + const { teamId, userId, isAll } = useInsightsOrgTeams(); + const [activeTab, setActiveTab] = useState("pending"); + const [routingTraceBookingUid, setRoutingTraceBookingUid] = useState(null); + const [formResponseId, setFormResponseId] = useState(null); + + const { sorting, limit, offset } = useDataTable(); + + const routingFormId = useFilterValue("routingFormId", ZSingleSelectFilterValue)?.data as string | undefined; + const reportedById = useFilterValue("reportedById", ZSingleSelectFilterValue)?.data as number | undefined; + + const utils = trpc.useUtils(); + + const { data, isPending } = trpc.viewer.bookings.getWrongAssignmentReports.useQuery( + { + teamId: teamId!, + isAll, + status: activeTab, + routingFormId: routingFormId ?? null, + reportedById: reportedById ?? null, + limit, + offset, + }, + { + enabled: !!teamId, + } + ); + + const updateStatusMutation = trpc.viewer.bookings.updateWrongAssignmentReportStatus.useMutation({ + onSuccess: () => { + showToast(t("status_updated_successfully"), "success"); + utils.viewer.bookings.getWrongAssignmentReports.invalidate(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const handleStatusChange = useCallback( + (reportId: string, newStatus: WrongAssignmentReportStatus) => { + updateStatusMutation.mutate({ reportId, status: newStatus }); + }, + [updateStatusMutation] + ); + + const getFacetedUniqueValues = useWrongAssignmentFacetedUniqueValues({ + userId, + teamId, + isAll, + }); + + const baseColumns = useWrongAssignmentColumns(); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + return [ + ...baseColumns, + columnHelper.display({ + id: "actions", + header: t("actions"), + size: 80, + cell: (info) => { + const report = info.row.original; + return ( + + + + + + + } + ToolbarRight={} + EmptyView={ + + } + /> + {routingTraceBookingUid && ( + { + if (!open) setRoutingTraceBookingUid(null); + }} + bookingUid={routingTraceBookingUid} + /> + )} + {formResponseId && ( + { + if (!open) setFormResponseId(null); + }} + formResponseId={formResponseId} + /> + )} + + ); +} diff --git a/apps/web/modules/insights/components/routing/index.ts b/apps/web/modules/insights/components/routing/index.ts index cbb60157a6..cddf343ba9 100644 --- a/apps/web/modules/insights/components/routing/index.ts +++ b/apps/web/modules/insights/components/routing/index.ts @@ -5,3 +5,4 @@ export { RoutingFunnel } from "./RoutingFunnel"; export { RoutingFunnelContent } from "./RoutingFunnelContent"; export { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton"; export { RoutingKPICards } from "./RoutingKPICards"; +export { WrongAssignmentReportsDashboard } from "./WrongAssignmentReportsDashboard"; diff --git a/apps/web/modules/insights/hooks/useWrongAssignmentColumns.tsx b/apps/web/modules/insights/hooks/useWrongAssignmentColumns.tsx new file mode 100644 index 0000000000..cbf929b0b8 --- /dev/null +++ b/apps/web/modules/insights/hooks/useWrongAssignmentColumns.tsx @@ -0,0 +1,178 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { useMemo } from "react"; + +import { ColumnFilterType } from "@calcom/features/data-table"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; +import { Badge } from "@calcom/ui/components/badge"; + +export type WrongAssignmentReportRow = { + id: string; + bookingUid: string; + correctAssignee: string | null; + additionalNotes: string; + status: WrongAssignmentReportStatus; + createdAt: Date; + reviewedAt: Date | null; + booking: { + id: number; + title: string; + startTime: Date; + endTime: Date; + user: { id: number; name: string | null; email: string } | null; + attendees: { name: string; email: string }[]; + routedFromRoutingFormReponse: { id: number } | null; + } | null; + reportedBy: { id: number; name: string | null; email: string }; + routingForm: { id: string; name: string } | null; + reviewedBy: { id: number; name: string | null; email: string } | null; +}; + +const statusVariantMap: Record = { + PENDING: "warning", + REVIEWED: "default", + RESOLVED: "success", + DISMISSED: "gray", +}; + +function formatDate(date: Date | string | null): string { + if (!date) return "-"; + const d = new Date(date); + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export const useWrongAssignmentColumns = () => { + const { t } = useLocale(); + + return useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + // Hidden filterable columns + columnHelper.accessor((row) => row.routingForm?.id ?? null, { + id: "routingFormId", + header: t("routing_form"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { type: ColumnFilterType.SINGLE_SELECT }, + }, + cell: () => null, + }), + columnHelper.accessor((row) => row.reportedBy.id, { + id: "reportedById", + header: t("reported_by"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { type: ColumnFilterType.SINGLE_SELECT }, + }, + cell: () => null, + }), + + // Display columns + columnHelper.accessor("booking", { + id: "booking", + header: t("booking"), + enableColumnFilter: false, + size: 220, + cell: (info) => { + const booking = info.getValue(); + if (!booking) return "-"; + return ( +
+
{booking.title}
+
{formatDate(booking.startTime)}
+
+ {t("host")}: {booking.user?.name ?? booking.user?.email ?? "-"} +
+
+ ); + }, + }), + columnHelper.accessor((row) => row.routingForm?.name ?? null, { + id: "routingFormName", + header: t("routing_form"), + enableColumnFilter: false, + size: 150, + cell: (info) => {info.getValue() ?? t("unknown")}, + }), + columnHelper.accessor((row) => row.reportedBy.name ?? row.reportedBy.email, { + id: "reportedByName", + header: t("reported_by"), + enableColumnFilter: false, + size: 150, + cell: (info) => { + const row = info.row.original; + return ( +
+
{row.reportedBy.name ?? "-"}
+
{row.reportedBy.email}
+
+ ); + }, + }), + columnHelper.accessor("correctAssignee", { + id: "correctAssignee", + header: t("correct_assignee"), + enableColumnFilter: false, + size: 150, + cell: (info) => {info.getValue() ?? "-"}, + }), + columnHelper.accessor("additionalNotes", { + id: "additionalNotes", + header: t("notes"), + enableColumnFilter: false, + size: 200, + cell: (info) => { + const val = info.getValue(); + return ( + + {val || "-"} + + ); + }, + }), + columnHelper.accessor("status", { + id: "status", + header: t("status"), + enableColumnFilter: false, + size: 100, + cell: (info) => { + const status = info.getValue(); + return {t(status.toLowerCase())}; + }, + }), + columnHelper.accessor("createdAt", { + id: "createdAt", + header: t("reported_at"), + enableColumnFilter: false, + size: 160, + cell: (info) => {formatDate(info.getValue())}, + }), + columnHelper.accessor("reviewedBy", { + id: "reviewedBy", + header: t("reviewed_by"), + enableColumnFilter: false, + size: 150, + cell: (info) => { + const reviewer = info.getValue(); + const row = info.row.original; + if (!reviewer) return "-"; + return ( +
+
{reviewer.name ?? "-"}
+
{formatDate(row.reviewedAt)}
+
+ ); + }, + }), + ]; + }, [t]); +}; diff --git a/apps/web/modules/insights/hooks/useWrongAssignmentFacetedUniqueValues.ts b/apps/web/modules/insights/hooks/useWrongAssignmentFacetedUniqueValues.ts new file mode 100644 index 0000000000..e0cf4358b8 --- /dev/null +++ b/apps/web/modules/insights/hooks/useWrongAssignmentFacetedUniqueValues.ts @@ -0,0 +1,61 @@ +import type { Table } from "@tanstack/react-table"; +import { useCallback } from "react"; + +import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table"; +import { trpc } from "@calcom/trpc/react"; + +export const useWrongAssignmentFacetedUniqueValues = ({ + userId, + teamId, + isAll, +}: { + userId: number | undefined; + teamId: number | undefined; + isAll: boolean; +}) => { + const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery( + { + userId, + teamId, + isAll, + }, + { + refetchOnWindowFocus: false, + } + ); + + const { data: users } = trpc.viewer.insights.userList.useQuery( + { + teamId, + isAll, + }, + { + refetchOnWindowFocus: false, + } + ); + + return useCallback( + (_: Table, columnId: string) => + (): Map => { + if (columnId === "routingFormId") { + return convertFacetedValuesToMap( + forms?.map((form) => ({ + label: form.name, + value: form.id, + })) ?? [] + ); + } else if (columnId === "reportedById") { + return convertFacetedValuesToMap( + users + ?.map((user) => ({ + label: user.name ?? user.email, + value: user.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)) ?? [] + ); + } + return new Map(); + }, + [forms, users] + ); +}; diff --git a/apps/web/modules/insights/views/insights-wrong-routing-view.tsx b/apps/web/modules/insights/views/insights-wrong-routing-view.tsx new file mode 100644 index 0000000000..329e872cb0 --- /dev/null +++ b/apps/web/modules/insights/views/insights-wrong-routing-view.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { DataTableProvider } from "@calcom/features/data-table/DataTableProvider"; +import { useSegments } from "~/data-table/hooks/useSegments"; +import { WrongAssignmentReportsDashboard } from "@calcom/web/modules/insights/components/routing"; + +import { InsightsOrgTeamsProvider } from "../components/context/InsightsOrgTeamsProvider"; + +export default function InsightsWrongRoutingPage({ timeZone }: { timeZone: string }) { + return ( + + + + + + ); +} diff --git a/apps/web/modules/shell/navigation/Navigation.tsx b/apps/web/modules/shell/navigation/Navigation.tsx index 0d94db29f3..241dea1b7f 100644 --- a/apps/web/modules/shell/navigation/Navigation.tsx +++ b/apps/web/modules/shell/navigation/Navigation.tsx @@ -135,6 +135,11 @@ const getNavigationItems = ( // icon: "phone", isCurrent: ({ pathname: path }) => path?.startsWith("/insights/call-history") ?? false, }, + { + name: "wrong_routing", + href: "/insights/wrong-routing", + isCurrent: ({ pathname: path }) => path?.startsWith("/insights/wrong-routing") ?? false, + }, ] : [ { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fc59e69270..acf398de63 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -842,6 +842,7 @@ "cancel_all_remaining": "Cancel all remaining", "apply": "Apply", "cancel_event": "Cancel event", + "report": "Report", "report_booking": "Report booking", "report_wrong_assignment": "Report wrong assignment", "wrong_assignment": "Wrong Assignment", @@ -1634,7 +1635,35 @@ "event_cancelled_trigger": "when event is canceled", "after_hosts_cal_video_no_show": "After hosts didn't join cal video", "after_guests_cal_video_no_show": "After guests didn't join cal video", - "wrong_assignment_report": "Wrong assignment report", + "wrong_routing": "Wrong routing", + "wrong_routing_description": "Review and manage reports of incorrectly routed bookings", + "view_form_submission": "View form submission", + "form_submission": "Form submission", + "wrong_assignment_report": "Wrong assignment report", + "wrong_assignment_reports": "Wrong Assignment Reports", + "wrong_assignment_reports_description": "Review and manage reports of incorrectly routed bookings", + "no_pending_reports": "No pending reports", + "no_pending_reports_description": "There are no wrong assignment reports waiting for review", + "no_reviewed_reports": "No reviewed reports", + "no_reviewed_reports_description": "There are no reviewed wrong assignment reports yet", + "no_handled_reports": "No handled reports", + "no_handled_reports_description": "There are no handled wrong assignment reports yet", + "handled": "Handled", + "no_responses_found": "No responses found", + "mark_as_reviewed": "Mark as reviewed", + "mark_as_resolved": "Mark as resolved", + "status_updated_successfully": "Status updated successfully", + "error_loading_data": "Error loading data", + "try_again_later": "Please try again later", + "reported_at": "Reported at", + "reviewed_by": "Reviewed by", + "correct_assignee": "Correct assignee", + "reopen": "Reopen", + "booking": "Booking", + "host": "Host", + "unknown": "Unknown", + "showing_x_of_y": "Showing {{x}} of {{y}}", + "select_team_to_view_reports": "Select a team or organization to view wrong assignment reports", "join_video_call": "Join the video call", "sign_in_to_cal_com": "Sign in to Cal.com", "log_in_to_cal_com": "Log in to Cal.com", @@ -3212,6 +3241,8 @@ "most_liked": "What did you like the most?", "review": "Review", "reviewed": "Reviewed", + "resolved": "Resolved", + "dismissed": "Dismissed", "unreviewed": "Unreviewed", "rating_url_variable": "Rating URL", "rating_url_info": "The URL for Rating Feedback Form", @@ -3432,6 +3463,7 @@ "add_new_field": "Add new field", "you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking", "form_response_not_found": "Form response not found", + "you_dont_have_access_to_view_this_form_response": "You don't have access to view this form response", "contact_owner": "Contact owner", "contact_owner_not_found": "Not found", "using_fallback_members": "Using fallback members", diff --git a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts index be4e7fc5ba..6d03ae81f6 100644 --- a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts @@ -49,6 +49,7 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { if (normalizedEmail) { const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({ where: { email: normalizedEmail }, + select: { id: true }, }); if (existingByEmail) { @@ -67,6 +68,7 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { if (normalizedPhone) { const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({ where: { phone: normalizedPhone }, + select: { id: true }, }); if (existingByPhone) { diff --git a/packages/features/bookings/repositories/WrongAssignmentReportRepository.test.ts b/packages/features/bookings/repositories/WrongAssignmentReportRepository.test.ts index 756fc94547..365004e259 100644 --- a/packages/features/bookings/repositories/WrongAssignmentReportRepository.test.ts +++ b/packages/features/bookings/repositories/WrongAssignmentReportRepository.test.ts @@ -1,7 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - import type { PrismaClient } from "@calcom/prisma"; - +import { beforeEach, describe, expect, it, vi } from "vitest"; import { WrongAssignmentReportRepository } from "./WrongAssignmentReportRepository"; describe("WrongAssignmentReportRepository", () => { diff --git a/packages/features/bookings/repositories/WrongAssignmentReportRepository.ts b/packages/features/bookings/repositories/WrongAssignmentReportRepository.ts index d071a4e2a6..5221aecc88 100644 --- a/packages/features/bookings/repositories/WrongAssignmentReportRepository.ts +++ b/packages/features/bookings/repositories/WrongAssignmentReportRepository.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; export class WrongAssignmentReportRepository { constructor(private readonly prismaClient: PrismaClient) {} @@ -24,4 +25,130 @@ export class WrongAssignmentReportRepository { select: { id: true }, }); } + + async findTeamIdById(id: string): Promise<{ id: string; teamId: number | null } | null> { + return this.prismaClient.wrongAssignmentReport.findUnique({ + where: { id }, + select: { + id: true, + teamId: true, + }, + }); + } + + async findByTeamIdsAndStatuses({ + teamIds, + statuses, + routingFormId, + reportedById, + limit, + offset, + }: { + teamIds: number[]; + statuses: WrongAssignmentReportStatus[]; + routingFormId?: string; + reportedById?: number; + limit: number; + offset: number; + }) { + const where: { + teamId: { in: number[] }; + status: { in: WrongAssignmentReportStatus[] }; + routingFormId?: string; + reportedById?: number; + } = { teamId: { in: teamIds }, status: { in: statuses } }; + if (routingFormId) where.routingFormId = routingFormId; + if (reportedById) where.reportedById = reportedById; + const [reports, totalCount] = await Promise.all([ + this.prismaClient.wrongAssignmentReport.findMany({ + where, + select: { + id: true, + bookingUid: true, + correctAssignee: true, + additionalNotes: true, + status: true, + createdAt: true, + reviewedAt: true, + booking: { + select: { + id: true, + title: true, + startTime: true, + endTime: true, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + }, + }, + routedFromRoutingFormReponse: { + select: { + id: true, + }, + }, + }, + }, + reportedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + routingForm: { + select: { + id: true, + name: true, + }, + }, + reviewedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + this.prismaClient.wrongAssignmentReport.count({ where }), + ]); + + return { reports, totalCount }; + } + + async updateStatus({ + id, + status, + reviewedById, + }: { + id: string; + status: WrongAssignmentReportStatus; + reviewedById: number; + }) { + return this.prismaClient.wrongAssignmentReport.update({ + where: { id }, + data: { + status, + reviewedById: status === "PENDING" ? null : reviewedById, + reviewedAt: status === "PENDING" ? null : new Date(), + }, + select: { + id: true, + status: true, + reviewedById: true, + reviewedAt: true, + }, + }); + } } diff --git a/packages/features/bookings/services/WrongAssignmentReportService.ts b/packages/features/bookings/services/WrongAssignmentReportService.ts new file mode 100644 index 0000000000..9f2b875bf2 --- /dev/null +++ b/packages/features/bookings/services/WrongAssignmentReportService.ts @@ -0,0 +1,198 @@ +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { Prisma } from "@calcom/prisma/client"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; + +import { BookingRepository } from "../repositories/BookingRepository"; +import { WrongAssignmentReportRepository } from "../repositories/WrongAssignmentReportRepository"; + +export interface IWrongAssignmentReportServiceDeps { + bookingRepo: BookingRepository; + wrongAssignmentReportRepo: WrongAssignmentReportRepository; + teamRepo: TeamRepository; +} + +const log = logger.getSubLogger({ prefix: ["WrongAssignmentReportService"] }); + +export class WrongAssignmentReportService { + constructor(private deps: IWrongAssignmentReportServiceDeps) {} + + async report(input: { + userId: number; + userEmail: string; + userName: string | null; + userLocale: string; + bookingUid: string; + correctAssignee?: string; + additionalNotes: string; + }) { + const { bookingRepo, wrongAssignmentReportRepo } = this.deps; + const t = await getTranslation(input.userLocale, "common"); + + const alreadyReported = await wrongAssignmentReportRepo.existsByBookingUid(input.bookingUid); + + if (alreadyReported) { + throw ErrorWithCode.Factory.BadRequest(t("wrong_assignment_already_reported")); + } + + const booking = await bookingRepo.findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ + bookingUid: input.bookingUid, + }); + + if (!booking) { + throw ErrorWithCode.Factory.NotFound("Booking not found"); + } + + const teamId = booking.eventType?.team?.id ?? null; + const orgId = booking.eventType?.team?.parentId ?? null; + const routingFormId = booking.routedFromRoutingFormReponse?.formId || null; + + let report: { id: string }; + try { + report = await wrongAssignmentReportRepo.createReport({ + bookingUid: booking.uid, + reportedById: input.userId, + correctAssignee: input.correctAssignee || null, + additionalNotes: input.additionalNotes, + teamId, + routingFormId, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw ErrorWithCode.Factory.BadRequest(t("wrong_assignment_already_reported")); + } + throw error; + } + + await this.sendWebhooks({ + booking, + teamId, + orgId, + reporter: { id: input.userId, email: input.userEmail, name: input.userName }, + correctAssignee: input.correctAssignee || null, + additionalNotes: input.additionalNotes || null, + }); + + return { + success: true, + message: t("wrong_assignment_reported"), + reportId: report.id, + }; + } + + async listReports(input: { + teamId: number; + isAll: boolean; + statuses: WrongAssignmentReportStatus[]; + routingFormId?: string; + reportedById?: number; + limit: number; + offset: number; + }) { + const { wrongAssignmentReportRepo, teamRepo } = this.deps; + + let teamIds: number[]; + + if (input.isAll) { + const childTeams = await teamRepo.findAllByParentId({ parentId: input.teamId, select: { id: true } }); + teamIds = [input.teamId, ...childTeams.map((t) => t.id)]; + } else { + teamIds = [input.teamId]; + } + + const { reports, totalCount } = await wrongAssignmentReportRepo.findByTeamIdsAndStatuses({ + teamIds, + statuses: [...input.statuses], + routingFormId: input.routingFormId, + reportedById: input.reportedById, + limit: input.limit, + offset: input.offset, + }); + + return { + reports, + totalCount, + hasMore: input.offset + reports.length < totalCount, + }; + } + + private async sendWebhooks(params: { + booking: NonNullable< + Awaited> + >; + teamId: number | null; + orgId: number | null; + reporter: { id: number; email: string; name: string | null }; + correctAssignee: string | null; + additionalNotes: string | null; + }) { + const { booking, teamId, orgId, reporter } = params; + + const webhookPayload = { + booking: { + uid: booking.uid, + id: booking.id, + title: booking.title, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + eventType: booking.eventType + ? { + id: booking.eventType.id, + title: booking.eventType.title, + slug: booking.eventType.slug, + teamId: teamId, + } + : null, + }, + report: { + reportedBy: reporter, + firstAssignmentReason: booking.assignmentReason[0]?.reasonString ?? null, + guest: booking.attendees[0]?.email ?? null, + host: { + email: booking.user?.email ?? null, + name: booking.user?.name ?? null, + }, + correctAssignee: params.correctAssignee, + additionalNotes: params.additionalNotes, + }, + }; + + try { + const webhooks = await getWebhooks({ + userId: booking.userId, + teamId, + orgId, + triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, + }); + + const webhookPromises = webhooks.map((webhook) => + sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, + createdAt: new Date().toISOString(), + webhook, + data: webhookPayload, + }).catch((error) => { + log.error(`Failed to send webhook to ${webhook.subscriberUrl}:`, error); + return { ok: false, status: 0 }; + }) + ); + + await Promise.allSettled(webhookPromises); + + log.info(`Wrong assignment report webhooks sent for booking ${booking.uid}`, { + teamId, + userId: booking.userId, + webhookCount: webhooks.length, + }); + } catch (error) { + log.error("Failed to send wrong assignment webhooks:", error); + } + } +} diff --git a/packages/features/routing-forms/repositories/PrismaRoutingFormResponseRepository.ts b/packages/features/routing-forms/repositories/PrismaRoutingFormResponseRepository.ts index 4f6bb455e1..56d567927c 100644 --- a/packages/features/routing-forms/repositories/PrismaRoutingFormResponseRepository.ts +++ b/packages/features/routing-forms/repositories/PrismaRoutingFormResponseRepository.ts @@ -11,10 +11,15 @@ export class PrismaRoutingFormResponseRepository implements RoutingFormResponseR where: { id, }, - include: { + select: { + response: true, form: { select: { fields: true, + name: true, + description: true, + userId: true, + teamId: true, }, }, }, diff --git a/packages/features/routing-forms/repositories/RoutingFormResponseRepository.interface.ts b/packages/features/routing-forms/repositories/RoutingFormResponseRepository.interface.ts index df6db01407..280486d016 100644 --- a/packages/features/routing-forms/repositories/RoutingFormResponseRepository.interface.ts +++ b/packages/features/routing-forms/repositories/RoutingFormResponseRepository.interface.ts @@ -3,7 +3,10 @@ import type { App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@calc export interface RoutingFormResponseRepositoryInterface { findByIdIncludeForm( id: number - ): Promise<(App_RoutingForms_FormResponse & { form: { fields: App_RoutingForms_Form["fields"] } }) | null>; + ): Promise<{ + response: App_RoutingForms_FormResponse["response"]; + form: Pick; + } | null>; findByBookingUidIncludeForm( bookingUid: string diff --git a/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql b/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql new file mode 100644 index 0000000000..6ff898f715 --- /dev/null +++ b/packages/prisma/migrations/20260130043627_add_reviewed_fields_to_wrong_assignment_report/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "public"."WrongAssignmentReport" ADD COLUMN "reviewedAt" TIMESTAMP(3), +ADD COLUMN "reviewedById" INTEGER; + +-- CreateIndex +CREATE INDEX "WrongAssignmentReport_reviewedById_idx" ON "public"."WrongAssignmentReport"("reviewedById"); + +-- AddForeignKey +ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20260211234000_add_composite_index_wrong_assignment_report/migration.sql b/packages/prisma/migrations/20260211234000_add_composite_index_wrong_assignment_report/migration.sql new file mode 100644 index 0000000000..ca86c99a2a --- /dev/null +++ b/packages/prisma/migrations/20260211234000_add_composite_index_wrong_assignment_report/migration.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "WrongAssignmentReport_teamId_status_createdAt_idx" ON "WrongAssignmentReport" ("teamId", "status", "createdAt"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 24cd687d11..ca3c239582 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -499,6 +499,7 @@ model User { updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") reportedBookings BookingReport[] @relation("ReportedBy") wrongAssignmentReports WrongAssignmentReport[] @relation("WrongAssignmentReportedBy") + reviewedWrongAssignmentReports WrongAssignmentReport[] @relation("WrongAssignmentReviewedBy") BookingInternalNote BookingInternalNote[] creationSource CreationSource? createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings") @@ -2579,6 +2580,9 @@ model WrongAssignmentReport { routingFormId String? routingForm App_RoutingForms_Form? @relation(fields: [routingFormId], references: [id], onDelete: SetNull) status WrongAssignmentReportStatus @default(PENDING) + reviewedById Int? + reviewedBy User? @relation("WrongAssignmentReviewedBy", fields: [reviewedById], references: [id], onDelete: SetNull) + reviewedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2586,7 +2590,9 @@ model WrongAssignmentReport { @@index([teamId]) @@index([routingFormId]) @@index([status]) + @@index([reviewedById]) @@index([createdAt]) + @@index([teamId, status, createdAt]) } enum BillingPeriod { diff --git a/packages/trpc/server/routers/apps/routing-forms/_router.ts b/packages/trpc/server/routers/apps/routing-forms/_router.ts index 9e4731bed4..6fd84e4dd0 100644 --- a/packages/trpc/server/routers/apps/routing-forms/_router.ts +++ b/packages/trpc/server/routers/apps/routing-forms/_router.ts @@ -63,6 +63,15 @@ const appRoutingForms = router({ ); return handler({ ctx, input }); }), + getFormResponseDisplay: authedProcedure + .input(ZFormByResponseIdInputSchema) + .query(async ({ ctx, input }) => { + const handler = await getHandler( + "getFormResponseDisplay", + () => import("./getFormResponseDisplay.handler") + ); + return handler({ ctx, input }); + }), formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => { const handler = await getHandler("formMutation", () => import("./formMutation.handler")); return handler({ ctx, input }); diff --git a/packages/trpc/server/routers/apps/routing-forms/getFormResponseDisplay.handler.ts b/packages/trpc/server/routers/apps/routing-forms/getFormResponseDisplay.handler.ts new file mode 100644 index 0000000000..446eda344f --- /dev/null +++ b/packages/trpc/server/routers/apps/routing-forms/getFormResponseDisplay.handler.ts @@ -0,0 +1,89 @@ +import type { z } from "zod"; + +import { getHumanReadableFieldResponseValue } from "@calcom/app-store/routing-forms/lib/responseData/getHumanReadableFieldResponseValue"; +import { getFieldWithOptions } from "@calcom/app-store/routing-forms/lib/selectOptions"; +import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; +import { zodFields } from "@calcom/app-store/routing-forms/zod"; +import { canAccessEntity } from "@calcom/features/pbac/lib/entityPermissionUtils.server"; +import { PrismaRoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormResponseRepository"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { TRPCError } from "@trpc/server"; + +import type { ZFormByResponseIdInputSchema } from "./getResponseWithFormFields.schema"; + +type GetFormResponseDisplayOptions = { + ctx: { + user: NonNullable; + }; + input: z.infer; +}; + +async function getFormResponseDisplayHandler({ ctx, input }: GetFormResponseDisplayOptions) { + const { user } = ctx; + const { formResponseId } = input; + const translate = await getTranslation(user.locale ?? "en", "common"); + + const routingFormResponseRepository = new PrismaRoutingFormResponseRepository(); + const formResponse = await routingFormResponseRepository.findByIdIncludeForm(formResponseId); + + if (!formResponse) { + throw new TRPCError({ + code: "NOT_FOUND", + message: translate("form_response_not_found"), + }); + } + + const form = formResponse.form; + + if (!(await canAccessEntity({ userId: form.userId, teamId: form.teamId }, user.id))) { + throw new TRPCError({ + code: "FORBIDDEN", + message: translate("you_dont_have_access_to_view_this_form_response"), + }); + } + + const fieldsParsed = zodFields.safeParse(form.fields); + if (!fieldsParsed.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Error parsing form fields", + }); + } + + const fields = (fieldsParsed.data ?? []).filter((f) => !f.deleted); + const response = formResponse.response as FormResponse; + + const responses = fields + .map((field) => { + const fieldResponse = response[field.id]; + if (!fieldResponse) return null; + + const fieldWithOptions = getFieldWithOptions(field); + const humanReadableValue = getHumanReadableFieldResponseValue({ + field: fieldWithOptions, + value: fieldResponse.value, + }); + const displayValue = Array.isArray(humanReadableValue) + ? humanReadableValue.join(", ") + : humanReadableValue; + + return { + fieldId: field.id, + label: field.label, + displayValue, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return { + form: { + name: form.name, + description: form.description, + }, + responses, + }; +} + +export default getFormResponseDisplayHandler; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index a0ffec34a3..105713ebac 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -1,6 +1,8 @@ +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; - +import { MembershipRole } from "@calcom/prisma/enums"; import authedProcedure from "../../../procedures/authedProcedure"; +import { createTeamPbacProcedure } from "../../../procedures/pbacProcedures"; import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZAddGuestsInputSchema } from "./addGuests.schema"; @@ -13,12 +15,13 @@ import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema"; import { ZGetBookingHistoryInputSchema } from "./getBookingHistory.schema"; import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema"; import { ZGetRoutingTraceInputSchema } from "./getRoutingTrace.schema"; +import { ZGetWrongAssignmentReportsInputSchema } from "./getWrongAssignmentReports.schema"; +import { ZHasWrongAssignmentReportInputSchema } from "./hasWrongAssignmentReport.schema"; import { ZReportBookingInputSchema } from "./reportBooking.schema"; import { ZReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema"; import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; -import { ZHasWrongAssignmentReportInputSchema } from "./hasWrongAssignmentReport.schema"; +import { ZUpdateWrongAssignmentReportStatusInputSchema } from "./updateWrongAssignmentReportStatus.schema"; import { bookingsProcedure } from "./util"; -import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; export const bookingsRouter = router({ get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { const { getHandler } = await import("./get.handler"); @@ -35,7 +38,7 @@ export const bookingsRouter = router({ return requestRescheduleHandler({ ctx, input, - source: "WEBAPP" + source: "WEBAPP", }); }), @@ -68,7 +71,7 @@ export const bookingsRouter = router({ ...input, actor: makeUserActor(ctx.user.uuid), actionSource: "WEBAPP", - } + }, }); }), @@ -156,4 +159,29 @@ export const bookingsRouter = router({ input, }); }), + getWrongAssignmentReports: createTeamPbacProcedure("booking.readTeamBookings", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + MembershipRole.MEMBER, + ]) + .input(ZGetWrongAssignmentReportsInputSchema) + .query(async ({ input }) => { + const { getWrongAssignmentReportsHandler } = await import("./getWrongAssignmentReports.handler"); + + return getWrongAssignmentReportsHandler({ + input, + }); + }), + updateWrongAssignmentReportStatus: authedProcedure + .input(ZUpdateWrongAssignmentReportStatusInputSchema) + .mutation(async ({ input, ctx }) => { + const { updateWrongAssignmentReportStatusHandler } = await import( + "./updateWrongAssignmentReportStatus.handler" + ); + + return updateWrongAssignmentReportStatusHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.handler.ts b/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.handler.ts new file mode 100644 index 0000000000..beb6cc05f4 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.handler.ts @@ -0,0 +1,30 @@ +import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository"; +import { WrongAssignmentReportService } from "@calcom/features/bookings/services/WrongAssignmentReportService"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import prisma from "@calcom/prisma"; + +import type { TGetWrongAssignmentReportsInputSchema } from "./getWrongAssignmentReports.schema"; +import { statusToEnumMap } from "./getWrongAssignmentReports.schema"; + +type GetWrongAssignmentReportsOptions = { + input: TGetWrongAssignmentReportsInputSchema; +}; + +export const getWrongAssignmentReportsHandler = async ({ input }: GetWrongAssignmentReportsOptions) => { + const service = new WrongAssignmentReportService({ + bookingRepo: new BookingRepository(prisma), + wrongAssignmentReportRepo: new WrongAssignmentReportRepository(prisma), + teamRepo: new TeamRepository(prisma), + }); + + return service.listReports({ + teamId: input.teamId, + isAll: input.isAll, + statuses: [...statusToEnumMap[input.status]], + routingFormId: input.routingFormId ?? undefined, + reportedById: input.reportedById ?? undefined, + limit: input.limit, + offset: input.offset, + }); +}; diff --git a/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.schema.ts b/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.schema.ts new file mode 100644 index 0000000000..f0b052663b --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getWrongAssignmentReports.schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; + +export const ZGetWrongAssignmentReportsInputSchema = z.object({ + teamId: z.number(), + isAll: z.boolean().default(false), + status: z.enum(["pending", "handled"]), + routingFormId: z.string().nullish(), + reportedById: z.number().nullish(), + limit: z.number().min(1).max(100).default(10), + offset: z.number().min(0).default(0), +}); + +export type TGetWrongAssignmentReportsInputSchema = z.infer; + +export const statusToEnumMap = { + pending: [WrongAssignmentReportStatus.PENDING], + handled: [ + WrongAssignmentReportStatus.REVIEWED, + WrongAssignmentReportStatus.RESOLVED, + WrongAssignmentReportStatus.DISMISSED, + ], +} as const; 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 6e358c4cab..db4f44ba80 100644 --- a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts +++ b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts @@ -1,5 +1,6 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -33,6 +34,11 @@ vi.mock("@calcom/features/bookings/services/BookingAccessService", () => { }, }; }); +vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => { + return { + TeamRepository: class MockTeamRepository {}, + }; +}); vi.mock("@calcom/features/webhooks/lib/getWebhooks"); vi.mock("@calcom/features/webhooks/lib/sendPayload"); vi.mock("@calcom/prisma", () => ({ @@ -41,7 +47,9 @@ vi.mock("@calcom/prisma", () => ({ vi.mock("@calcom/lib/server/i18n", () => ({ getTranslation: vi.fn().mockResolvedValue((key: string) => { const translations: Record = { - wrong_assignment_already_reported: "A wrong assignment report has already been submitted for this booking", + wrong_assignment_already_reported: + "A wrong assignment report has already been submitted for this booking", + wrong_assignment_reported: "Wrong assignment reported successfully", }; return translations[key] || key; }), @@ -136,7 +144,7 @@ describe("reportWrongAssignmentHandler", () => { ctx: { user: mockUser }, input: mockInput, }) - ).rejects.toThrow(TRPCError); + ).rejects.toThrow(ErrorWithCode); await expect( reportWrongAssignmentHandler({ @@ -144,7 +152,6 @@ describe("reportWrongAssignmentHandler", () => { input: mockInput, }) ).rejects.toMatchObject({ - code: "NOT_FOUND", message: "Booking not found", }); }); @@ -173,6 +180,16 @@ describe("reportWrongAssignmentHandler", () => { mockFindByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason.mockResolvedValue(mockBooking); mockExistsByBookingUid.mockResolvedValue(true); + await expect( + reportWrongAssignmentHandler({ + ctx: { user: mockUser }, + input: { + bookingUid: "test-booking-uid", + additionalNotes: "Duplicate report", + }, + }) + ).rejects.toThrow(ErrorWithCode); + await expect( reportWrongAssignmentHandler({ ctx: { user: mockUser }, @@ -182,7 +199,6 @@ describe("reportWrongAssignmentHandler", () => { }, }) ).rejects.toMatchObject({ - code: "BAD_REQUEST", message: "A wrong assignment report has already been submitted for this booking", }); diff --git a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts index 885df86fc8..6c409ef18f 100644 --- a/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts @@ -1,17 +1,11 @@ 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 logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/lib/server/i18n"; +import { WrongAssignmentReportService } from "@calcom/features/bookings/services/WrongAssignmentReportService"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import prisma from "@calcom/prisma"; -import { Prisma } from "@calcom/prisma/client"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - import { TRPCError } from "@trpc/server"; - import type { TReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema"; type ReportWrongAssignmentOptions = { @@ -21,136 +15,32 @@ type ReportWrongAssignmentOptions = { input: TReportWrongAssignmentInputSchema; }; -const log = logger.getSubLogger({ prefix: ["reportWrongAssignmentHandler"] }); - export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAssignmentOptions) => { const { user } = ctx; - const { bookingUid, correctAssignee, additionalNotes } = input; - const t = await getTranslation(user.locale ?? "en", "common"); - const bookingRepository = new BookingRepository(prisma); - const wrongAssignmentReportRepo = new WrongAssignmentReportRepository(prisma); const bookingAccessService = new BookingAccessService(prisma); - const hasAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({ userId: user.id, - bookingUid, + bookingUid: input.bookingUid, }); if (!hasAccess) { throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this booking" }); } - const booking = await bookingRepository.findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ - bookingUid, + const service = new WrongAssignmentReportService({ + bookingRepo: new BookingRepository(prisma), + wrongAssignmentReportRepo: new WrongAssignmentReportRepository(prisma), + teamRepo: new TeamRepository(prisma), }); - if (!booking) { - throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" }); - } - - 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); - - if (alreadyReported) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: t("wrong_assignment_already_reported"), - }); - } - - let report: { id: string }; - try { - report = await wrongAssignmentReportRepo.createReport({ - bookingUid: booking.uid, - reportedById: user.id, - correctAssignee: correctAssignee || null, - additionalNotes, - teamId, - routingFormId, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: t("wrong_assignment_already_reported"), - }); - } - throw error; - } - - const webhookPayload = { - booking: { - uid: booking.uid, - id: booking.id, - title: booking.title, - startTime: booking.startTime, - endTime: booking.endTime, - status: booking.status, - eventType: booking.eventType - ? { - id: booking.eventType.id, - title: booking.eventType.title, - slug: booking.eventType.slug, - teamId: teamId, - } - : null, - }, - report: { - reportedBy: { - id: user.id, - email: user.email, - name: user.name, - }, - firstAssignmentReason: booking.assignmentReason[0]?.reasonString ?? null, - guest: booking.attendees[0]?.email ?? null, - host: { - email: booking.user?.email ?? null, - name: booking.user?.name ?? null, - }, - correctAssignee: correctAssignee || null, - additionalNotes: additionalNotes || null, - }, - }; - - try { - const webhooks = await getWebhooks({ - userId: booking.userId, - teamId, - orgId, - triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, - }); - - const webhookPromises = webhooks.map((webhook) => - sendGenericWebhookPayload({ - secretKey: webhook.secret, - triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, - createdAt: new Date().toISOString(), - webhook, - data: webhookPayload, - }).catch((error) => { - log.error(`Failed to send webhook to ${webhook.subscriberUrl}:`, error); - return { ok: false, status: 0 }; - }) - ); - - await Promise.allSettled(webhookPromises); - - log.info(`Wrong assignment report sent for booking ${booking.uid}`, { - teamId, - userId: booking.userId, - webhookCount: webhooks.length, - }); - } catch (error) { - log.error("Failed to send wrong assignment webhooks:", error); - } - - return { - success: true, - message: "Wrong assignment reported successfully", - reportId: report.id, - }; + return service.report({ + userId: user.id, + userEmail: user.email, + userName: user.name, + userLocale: user.locale ?? "en", + bookingUid: input.bookingUid, + correctAssignee: input.correctAssignee, + additionalNotes: input.additionalNotes, + }); }; diff --git a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts new file mode 100644 index 0000000000..707b2ca59c --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.handler.ts @@ -0,0 +1,68 @@ +import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { TRPCError } from "@trpc/server"; + +import type { TUpdateWrongAssignmentReportStatusInputSchema } from "./updateWrongAssignmentReportStatus.schema"; + +type UpdateWrongAssignmentReportStatusOptions = { + ctx: { + user: NonNullable; + }; + input: TUpdateWrongAssignmentReportStatusInputSchema; +}; + +export const updateWrongAssignmentReportStatusHandler = async ({ + ctx, + input, +}: UpdateWrongAssignmentReportStatusOptions) => { + const { user } = ctx; + const { reportId, status } = input; + + const repo = new WrongAssignmentReportRepository(prisma); + + const report = await repo.findTeamIdById(reportId); + + if (!report) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Report not found", + }); + } + + if (!report.teamId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Report is not associated with a team", + }); + } + + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: report.teamId, + permission: "booking.update", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this team", + }); + } + + const updatedReport = await repo.updateStatus({ + id: reportId, + status, + reviewedById: user.id, + }); + + return { + success: true, + report: updatedReport, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.schema.ts b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.schema.ts new file mode 100644 index 0000000000..1fa2e907fd --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/updateWrongAssignmentReportStatus.schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { WrongAssignmentReportStatus } from "@calcom/prisma/enums"; + +export const ZUpdateWrongAssignmentReportStatusInputSchema = z.object({ + reportId: z.string().uuid(), + status: z.nativeEnum(WrongAssignmentReportStatus), +}); + +export type TUpdateWrongAssignmentReportStatusInputSchema = z.infer< + typeof ZUpdateWrongAssignmentReportStatusInputSchema +>;