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:
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,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+9
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "WrongAssignmentReport_teamId_status_createdAt_idx" ON "WrongAssignmentReport" ("teamId", "status", "createdAt");
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
+68
@@ -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,
|
||||
};
|
||||
};
|
||||
+12
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user