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 +>;