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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* chore: add migration for routingFormId in WrongAssignmentReport

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* fix: move hooks before early return and fix indentation

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* test: update mocks for findUnique and i18n in unit tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: throw UNAUTHORIZED in hasWrongAssignmentReport and squash migrations

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: move wrong assignment reports to its own tab under Insights

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <j.auyeung419@gmail.com>

* feat: add missing i18n strings for wrong assignment reports dashboard

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: resolve type errors in RoutingFormResponseSheet and wrong-routing view

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add seed script for wrong assignment reports test data

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <j.auyeung419@gmail.com>

* Fix routing sheet UI

* fix: use appropriate error message in getFormResponseDisplay handler

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: localize hard-coded success message in WrongAssignmentReportService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <noreply@anthropic.com>

* 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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* fix: remove stale TRPCError assertion in test (service throws ErrorWithCode)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: remove unused findByTeamIdAndStatus and findByTeamIdAndStatuses methods

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: add composite index on WrongAssignmentReport and narrow findByIdIncludeForm select

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: use lightweight findTeamIdById in update-status handler instead of findById

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
This commit is contained in:
authored and GitHub committed 2026-02-12 13:02:33 +00:00
1 parent 50c5423ba7
commit 7cfb053026
31 files changed
+1400 -189

No files matched your search

@@ -359,7 +359,6 @@ type RouteActionSelectorProps = {
onCustomEventTypeSlugChange?: (slug: string) => void;
eventTypePrefix?: string;
fieldIdentifiers?: string[];
t: (key: string, options?: Record<string, string>) => string;
};
const RouteActionSelector = ({
@@ -375,8 +374,8 @@ const RouteActionSelector = ({
onCustomEventTypeSlugChange,
eventTypePrefix = "",
fieldIdentifiers = [],
t,
}: RouteActionSelectorProps) => {
const { t } = useLocale();
return (
<div className={classNames("flex w-full flex-col gap-2 text-sm lg:flex-row", className)}>
<div className="flex grow items-center gap-2">
@@ -803,7 +802,6 @@ const Route = ({
onCustomEventTypeSlugChange={setCustomFallbackEventTypeSlug}
eventTypePrefix={eventTypePrefix}
fieldIdentifiers={fieldIdentifiers}
t={t}
/>
</div>
{isOrganization && (
@@ -899,7 +897,6 @@ const Route = ({
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
eventTypePrefix={eventTypePrefix}
fieldIdentifiers={fieldIdentifiers}
t={t}
/>
) : (
<div className="bg-default border-subtle my-3 rounded-xl border p-2">
@@ -938,7 +935,6 @@ const Route = ({
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
eventTypePrefix={eventTypePrefix}
fieldIdentifiers={fieldIdentifiers}
t={t}
/>
</div>
{attributesQueryBuilder}
@@ -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 <InsightsWrongRoutingPage timeZone={user?.timeZone ?? "UTC"} />;
}
@@ -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 (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>{t("form_submission")}</SheetTitle>
</SheetHeader>
<SheetBody>
{isLoading && (
<div className="flex flex-col gap-4 py-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex flex-col gap-1.5">
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
<div className="bg-muted h-5 w-full animate-pulse rounded" />
</div>
))}
</div>
)}
{!isLoading && (!data?.responses || data.responses.length === 0) && (
<p className="text-subtle text-sm">{t("no_responses_found")}</p>
)}
{!isLoading && data?.responses && data.responses.length > 0 && (
<div className="flex flex-col gap-4">
{data.form?.name && (
<div className="border-subtle border-b pb-3">
<p className="text-emphasis text-sm font-medium">{data.form.name}</p>
{data.form.description && (
<p className="text-subtle text-xs">{data.form.description}</p>
)}
</div>
)}
{data.responses.map((item) => (
<div key={item.fieldId} className="flex flex-col gap-1">
<label className="text-subtle text-xs font-medium uppercase tracking-wide">
{item.label}
</label>
<p className="text-emphasis text-sm">{item.displayValue}</p>
</div>
))}
</div>
)}
</SheetBody>
</SheetContent>
</Sheet>
);
}
@@ -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 (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>{t("routing_trace")}</SheetTitle>
<SheetHeader showCloseButton={!showReportButton} className="w-full">
{showReportButton ? (
<div className="flex w-full items-center justify-between">
<SheetTitle>{t("routing_trace")}</SheetTitle>
<div className="flex items-center gap-2 pl-2">
<Button
color="secondary"
size="sm"
StartIcon="flag"
disabled={hasExistingReport}
onClick={onReport}>
{t("report")}
</Button>
</div>
</div>
) : (
<SheetTitle>{t("routing_trace")}</SheetTitle>
)}
</SheetHeader>
<SheetBody>
{isLoading && (
@@ -54,7 +88,9 @@ export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTrac
return (
<div key={idx} className="relative flex gap-3 pb-6 last:pb-0">
{/* Timeline connector line */}
{!isLast && <div className="border-subtle absolute left-4 top-8 bottom-0 border-l" />}
{!isLast && (
<div className="border-subtle absolute left-4 top-8 bottom-0 border-l" />
)}
{/* Icon circle */}
<div className="bg-default border-subtle relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border">
<DomainIcon domain={step.domain} />
@@ -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 && (
<WrongAssignmentDialog
isOpenDialog={isOpenWrongAssignmentDialog}
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
booking={booking}
/>
)}
{booking.assignmentReasonSortedByCreatedAt.length > 0 && (
<RoutingTraceSheet
isOpen={isOpenRoutingTraceSheet}
setIsOpen={setIsOpenRoutingTraceSheet}
bookingUid={booking.uid}
/>
<>
<WrongAssignmentDialog
isOpenDialog={isOpenWrongAssignmentDialog}
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
booking={booking}
/>
<RoutingTraceSheet
isOpen={isOpenRoutingTraceSheet}
setIsOpen={setIsOpenRoutingTraceSheet}
bookingUid={booking.uid}
onReport={() => setIsOpenWrongAssignmentDialog(true)}
hasExistingReport={!!booking.report}
/>
</>
)}
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
@@ -1,9 +1,5 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
@@ -15,8 +11,8 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus } from "@calcom/prisma/enums";
import {
bookingMetadataSchema,
eventTypeBookingFields,
EventTypeMetaDataSchema,
eventTypeBookingFields,
} from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
@@ -25,28 +21,30 @@ import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { SegmentedControl } from "@calcom/ui/components/segmented-control";
import {
Sheet,
SheetContent,
SheetBody,
SheetHeader,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@calcom/ui/components/sheet";
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import Link from "next/link";
import { useMemo } from "react";
import type { z } from "zod";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
import type { BookingListingStatus } from "../../../components/booking/types";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
import { SegmentedControl } from "@calcom/ui/components/segmented-control";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
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 (
@@ -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<TabType>("pending");
const [routingTraceBookingUid, setRoutingTraceBookingUid] = useState<string | null>(null);
const [formResponseId, setFormResponseId] = useState<number | null>(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<WrongAssignmentReportRow>();
return [
...baseColumns,
columnHelper.display({
id: "actions",
header: t("actions"),
size: 80,
cell: (info) => {
const report = info.row.original;
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button color="secondary" size="sm" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{report.bookingUid && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="git-merge"
onClick={() => setRoutingTraceBookingUid(report.bookingUid)}>
{t("routing_trace")}
</DropdownItem>
</DropdownMenuItem>
{report.booking?.routedFromRoutingFormReponse?.id && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="file-text"
onClick={() =>
setFormResponseId(report.booking?.routedFromRoutingFormReponse?.id ?? null)
}>
{t("view_form_submission")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
{report.status !== WrongAssignmentReportStatus.PENDING && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="rotate-ccw"
onClick={() => handleStatusChange(report.id, WrongAssignmentReportStatus.PENDING)}>
{t("reopen")}
</DropdownItem>
</DropdownMenuItem>
)}
{report.status !== WrongAssignmentReportStatus.REVIEWED && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="check"
onClick={() => handleStatusChange(report.id, WrongAssignmentReportStatus.REVIEWED)}>
{t("mark_as_reviewed")}
</DropdownItem>
</DropdownMenuItem>
)}
{report.status !== WrongAssignmentReportStatus.RESOLVED && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="check-check"
onClick={() => handleStatusChange(report.id, WrongAssignmentReportStatus.RESOLVED)}>
{t("mark_as_resolved")}
</DropdownItem>
</DropdownMenuItem>
)}
{report.status !== WrongAssignmentReportStatus.DISMISSED && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="x"
onClick={() => handleStatusChange(report.id, WrongAssignmentReportStatus.DISMISSED)}>
{t("dismiss")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
);
},
}),
];
}, [baseColumns, t, handleStatusChange]);
const processedData = useMemo(() => {
return (data?.reports ?? []) as WrongAssignmentReportRow[];
}, [data]);
const table = useReactTable<WrongAssignmentReportRow>({
data: processedData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
size: 150,
},
initialState: {
columnVisibility: {
routingFormId: false,
reportedById: false,
},
},
getFacetedUniqueValues: getFacetedUniqueValues,
});
if (!teamId) {
return (
<EmptyScreen
headline={t("wrong_assignment_reports")}
description={t("select_team_to_view_reports")}
Icon="users"
/>
);
}
return (
<>
<DataTableWrapper<WrongAssignmentReportRow>
table={table}
isPending={isPending}
rowClassName="min-h-14"
paginationMode="standard"
totalRowCount={data?.totalCount}
LoaderView={<DataTableSkeleton columns={6} columnWidths={[220, 150, 150, 150, 100, 80]} />}
ToolbarLeft={
<>
<OrgTeamsFilter />
<Button
color={activeTab === "pending" ? "primary" : "secondary"}
size="sm"
onClick={() => setActiveTab("pending")}>
{t("pending")}
</Button>
<Button
color={activeTab === "handled" ? "primary" : "secondary"}
size="sm"
onClick={() => setActiveTab("handled")}>
{t("handled")}
</Button>
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={<DataTableFilters.ClearFiltersButton />}
EmptyView={
<EmptyScreen
headline={activeTab === "pending" ? t("no_pending_reports") : t("no_handled_reports")}
description={
activeTab === "pending"
? t("no_pending_reports_description")
: t("no_handled_reports_description")
}
Icon="file-text"
className="bg-muted mb-16"
iconWrapperClassName="bg-default"
dashedBorder={false}
/>
}
/>
{routingTraceBookingUid && (
<RoutingTraceSheet
isOpen={!!routingTraceBookingUid}
setIsOpen={(open) => {
if (!open) setRoutingTraceBookingUid(null);
}}
bookingUid={routingTraceBookingUid}
/>
)}
{formResponseId && (
<RoutingFormResponseSheet
isOpen={!!formResponseId}
setIsOpen={(open) => {
if (!open) setFormResponseId(null);
}}
formResponseId={formResponseId}
/>
)}
</>
);
}
@@ -5,3 +5,4 @@ export { RoutingFunnel } from "./RoutingFunnel";
export { RoutingFunnelContent } from "./RoutingFunnelContent";
export { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
export { RoutingKPICards } from "./RoutingKPICards";
export { WrongAssignmentReportsDashboard } from "./WrongAssignmentReportsDashboard";
@@ -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<WrongAssignmentReportStatus, "default" | "success" | "warning" | "gray"> = {
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<WrongAssignmentReportRow>();
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 (
<div>
<div className="text-emphasis text-sm font-medium">{booking.title}</div>
<div className="text-subtle text-xs">{formatDate(booking.startTime)}</div>
<div className="text-subtle text-xs">
{t("host")}: {booking.user?.name ?? booking.user?.email ?? "-"}
</div>
</div>
);
},
}),
columnHelper.accessor((row) => row.routingForm?.name ?? null, {
id: "routingFormName",
header: t("routing_form"),
enableColumnFilter: false,
size: 150,
cell: (info) => <span className="text-emphasis text-sm">{info.getValue() ?? t("unknown")}</span>,
}),
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 (
<div>
<div className="text-emphasis text-sm">{row.reportedBy.name ?? "-"}</div>
<div className="text-subtle text-xs">{row.reportedBy.email}</div>
</div>
);
},
}),
columnHelper.accessor("correctAssignee", {
id: "correctAssignee",
header: t("correct_assignee"),
enableColumnFilter: false,
size: 150,
cell: (info) => <span className="text-emphasis text-sm">{info.getValue() ?? "-"}</span>,
}),
columnHelper.accessor("additionalNotes", {
id: "additionalNotes",
header: t("notes"),
enableColumnFilter: false,
size: 200,
cell: (info) => {
const val = info.getValue();
return (
<span className="text-emphasis max-w-xs truncate text-sm" title={val}>
{val || "-"}
</span>
);
},
}),
columnHelper.accessor("status", {
id: "status",
header: t("status"),
enableColumnFilter: false,
size: 100,
cell: (info) => {
const status = info.getValue();
return <Badge variant={statusVariantMap[status]}>{t(status.toLowerCase())}</Badge>;
},
}),
columnHelper.accessor("createdAt", {
id: "createdAt",
header: t("reported_at"),
enableColumnFilter: false,
size: 160,
cell: (info) => <span className="text-subtle text-sm">{formatDate(info.getValue())}</span>,
}),
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 (
<div>
<div className="text-emphasis text-sm">{reviewer.name ?? "-"}</div>
<div className="text-subtle text-xs">{formatDate(row.reviewedAt)}</div>
</div>
);
},
}),
];
}, [t]);
};
@@ -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(
<TData>(_: Table<TData>, columnId: string) =>
(): Map<FacetedValue, number> => {
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<FacetedValue, number>();
},
[forms, users]
);
};
@@ -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 (
<DataTableProvider tableIdentifier="/insights/wrong-routing" useSegments={useSegments} timeZone={timeZone}>
<InsightsOrgTeamsProvider>
<WrongAssignmentReportsDashboard />
</InsightsOrgTeamsProvider>
</DataTableProvider>
);
}
@@ -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,
},
]
: [
{
+33 -1
View File
@@ -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",
@@ -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) {
@@ -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", () => {
@@ -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,
},
});
}
}
@@ -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<ReturnType<BookingRepository["findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason"]>>
>;
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);
}
}
}
@@ -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,
},
},
},
@@ -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<App_RoutingForms_Form, "fields" | "name" | "description" | "userId" | "teamId">;
} | null>;
findByBookingUidIncludeForm(
bookingUid: string
@@ -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;
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "WrongAssignmentReport_teamId_status_createdAt_idx" ON "WrongAssignmentReport" ("teamId", "status", "createdAt");
+6
View File
@@ -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 {
@@ -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 });
@@ -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<TrpcSessionUser>;
};
input: z.infer<typeof ZFormByResponseIdInputSchema>;
};
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<typeof item> => item !== null);
return {
form: {
name: form.name,
description: form.description,
},
responses,
};
}
export default getFormResponseDisplayHandler;
@@ -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,
});
}),
});
@@ -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,
});
};
@@ -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<typeof ZGetWrongAssignmentReportsInputSchema>;
export const statusToEnumMap = {
pending: [WrongAssignmentReportStatus.PENDING],
handled: [
WrongAssignmentReportStatus.REVIEWED,
WrongAssignmentReportStatus.RESOLVED,
WrongAssignmentReportStatus.DISMISSED,
],
} as const;
@@ -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<string, string> = {
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",
});
@@ -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,
});
};
@@ -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<TrpcSessionUser>;
};
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,
};
};
@@ -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
>;