feat: Write wrong assignment reports to the database (#27405)

* 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>

* 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>

* 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>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2026-02-10 08:00:01 -03:00
committed by GitHub
co-authored by joe@cal.com <j.auyeung419@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent c3a8301b0c
commit c0504993a6
12 changed files with 513 additions and 2 deletions
@@ -164,7 +164,7 @@ function AssigneeSection(props: AssigneeSectionProps): JSX.Element {
field: ControllerRenderProps<FormValues, "correctAssignee">;
}): JSX.Element => {
const handleChange = (option: TeamMemberOption | null): void => {
if (option) field.onChange(option.value);
field.onChange(option ? option.value : "");
};
return (
<Select
@@ -234,6 +234,12 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
},
});
const { data: existingReport, isPending: isCheckingReport } = trpc.viewer.bookings.hasWrongAssignmentReport.useQuery(
{ bookingUid },
{ enabled: isOpenDialog }
);
const alreadyReported = existingReport?.hasReport ?? false;
const teamIdForQuery = teamId ?? 0;
const { data: teamMembersData } = trpc.viewer.teams.listMembers.useQuery(
{ teamId: teamIdForQuery, limit: 100 },
@@ -311,6 +317,10 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
errorMessage={errors.additionalNotes?.message}
/>
{alreadyReported && (
<Alert severity="warning" message={t("wrong_assignment_already_reported")} className="mb-4" />
)}
<Alert severity="info" title={t("did_you_know")} message={t("wrong_assignment_crm_info")} />
</div>
</div>
@@ -319,7 +329,7 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
<Button type="button" color="secondary" onClick={handleCloseClick} disabled={isPending}>
{t("close")}
</Button>
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
<Button type="submit" color="primary" disabled={isPending || alreadyReported || isCheckingReport} loading={isPending || isCheckingReport}>
{t("submit")}
</Button>
</DialogFooter>
@@ -840,6 +840,7 @@
"report_wrong_assignment": "Report wrong assignment",
"wrong_assignment": "Wrong Assignment",
"wrong_assignment_reported": "Wrong assignment reported successfully",
"wrong_assignment_already_reported": "A wrong assignment report has already been submitted for this booking.",
"routing_reason": "Routing Reason",
"no_routing_reason": "No routing reason available",
"who_booked_it": "Who booked it?",
@@ -2162,6 +2162,11 @@ export class BookingRepository implements IBookingRepository {
reasonEnum: true,
},
},
routedFromRoutingFormReponse: {
select: {
formId: true,
},
},
},
});
}
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrismaClient } from "@calcom/prisma";
import { WrongAssignmentReportRepository } from "./WrongAssignmentReportRepository";
describe("WrongAssignmentReportRepository", () => {
let repository: WrongAssignmentReportRepository;
const mockPrisma = {
wrongAssignmentReport: {
findUnique: vi.fn(),
create: vi.fn(),
},
} as unknown as PrismaClient;
beforeEach(() => {
vi.clearAllMocks();
repository = new WrongAssignmentReportRepository(mockPrisma);
});
describe("existsByBookingUid", () => {
it("should return true when a report exists for the booking", async () => {
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue({ id: "report-123" });
const result = await repository.existsByBookingUid("test-booking-uid");
expect(result).toBe(true);
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
where: { bookingUid: "test-booking-uid" },
select: { id: true },
});
});
it("should return false when no report exists for the booking", async () => {
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue(null);
const result = await repository.existsByBookingUid("non-existent-booking-uid");
expect(result).toBe(false);
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
where: { bookingUid: "non-existent-booking-uid" },
select: { id: true },
});
});
it("should handle empty string bookingUid", async () => {
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue(null);
const result = await repository.existsByBookingUid("");
expect(result).toBe(false);
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
where: { bookingUid: "" },
select: { id: true },
});
});
});
describe("createReport", () => {
it("should create a report with all fields", async () => {
const input = {
bookingUid: "test-booking-uid",
reportedById: 1,
correctAssignee: "correct@example.com",
additionalNotes: "This booking was assigned incorrectly",
teamId: 5,
routingFormId: "routing-form-123",
};
const mockCreatedReport = { id: "report-uuid-123" };
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
const result = await repository.createReport(input);
expect(result).toEqual({ id: "report-uuid-123" });
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
data: input,
select: { id: true },
});
});
it("should create a report with null optional fields", async () => {
const input = {
bookingUid: "test-booking-uid-2",
reportedById: 2,
correctAssignee: null,
additionalNotes: "Wrong person",
teamId: null,
routingFormId: null,
};
const mockCreatedReport = { id: "report-uuid-456" };
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
const result = await repository.createReport(input);
expect(result).toEqual({ id: "report-uuid-456" });
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
data: input,
select: { id: true },
});
});
it("should create a report with empty additionalNotes", async () => {
const input = {
bookingUid: "test-booking-uid-3",
reportedById: 3,
correctAssignee: "someone@example.com",
additionalNotes: "",
teamId: 10,
routingFormId: "routing-form-456",
};
const mockCreatedReport = { id: "report-uuid-789" };
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
const result = await repository.createReport(input);
expect(result).toEqual({ id: "report-uuid-789" });
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
data: input,
select: { id: true },
});
});
it("should propagate database errors", async () => {
const input = {
bookingUid: "test-booking-uid",
reportedById: 1,
correctAssignee: null,
additionalNotes: "Notes",
teamId: null,
routingFormId: null,
};
mockPrisma.wrongAssignmentReport.create.mockRejectedValue(new Error("Database connection failed"));
await expect(repository.createReport(input)).rejects.toThrow("Database connection failed");
});
it("should propagate foreign key constraint errors", async () => {
const input = {
bookingUid: "non-existent-booking",
reportedById: 999999,
correctAssignee: null,
additionalNotes: "Notes",
teamId: null,
routingFormId: null,
};
mockPrisma.wrongAssignmentReport.create.mockRejectedValue(
new Error("Foreign key constraint failed on the field: `bookingUid`")
);
await expect(repository.createReport(input)).rejects.toThrow("Foreign key constraint failed");
});
});
});
@@ -0,0 +1,27 @@
import type { PrismaClient } from "@calcom/prisma";
export class WrongAssignmentReportRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async existsByBookingUid(bookingUid: string): Promise<boolean> {
const report = await this.prismaClient.wrongAssignmentReport.findUnique({
where: { bookingUid },
select: { id: true },
});
return !!report;
}
async createReport(input: {
bookingUid: string;
reportedById: number;
correctAssignee: string | null;
additionalNotes: string;
teamId: number | null;
routingFormId: string | null;
}): Promise<{ id: string }> {
return this.prismaClient.wrongAssignmentReport.create({
data: input,
select: { id: true },
});
}
}
@@ -0,0 +1,48 @@
-- CreateEnum
CREATE TYPE "public"."WrongAssignmentReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'RESOLVED', 'DISMISSED');
-- CreateTable
CREATE TABLE "public"."WrongAssignmentReport" (
"id" UUID NOT NULL,
"bookingUid" TEXT NOT NULL,
"reportedById" INTEGER,
"correctAssignee" TEXT,
"additionalNotes" TEXT NOT NULL,
"teamId" INTEGER,
"routingFormId" TEXT,
"status" "public"."WrongAssignmentReportStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WrongAssignmentReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WrongAssignmentReport_bookingUid_key" ON "public"."WrongAssignmentReport"("bookingUid");
-- CreateIndex
CREATE INDEX "WrongAssignmentReport_reportedById_idx" ON "public"."WrongAssignmentReport"("reportedById");
-- CreateIndex
CREATE INDEX "WrongAssignmentReport_teamId_idx" ON "public"."WrongAssignmentReport"("teamId");
-- CreateIndex
CREATE INDEX "WrongAssignmentReport_routingFormId_idx" ON "public"."WrongAssignmentReport"("routingFormId");
-- CreateIndex
CREATE INDEX "WrongAssignmentReport_status_idx" ON "public"."WrongAssignmentReport"("status");
-- CreateIndex
CREATE INDEX "WrongAssignmentReport_createdAt_idx" ON "public"."WrongAssignmentReport"("createdAt");
-- AddForeignKey
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_routingFormId_fkey" FOREIGN KEY ("routingFormId") REFERENCES "public"."App_RoutingForms_Form"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+34
View File
@@ -498,6 +498,7 @@ model User {
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
reportedBookings BookingReport[] @relation("ReportedBy")
wrongAssignmentReports WrongAssignmentReport[] @relation("WrongAssignmentReportedBy")
BookingInternalNote BookingInternalNote[]
creationSource CreationSource?
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
@@ -624,6 +625,7 @@ model Team {
calAiPhoneNumbers CalAiPhoneNumber[]
agents Agent[]
bookingReports BookingReport[]
wrongAssignmentReports WrongAssignmentReport[]
features TeamFeatures[]
@@ -923,6 +925,7 @@ model Booking {
routingFormResponses RoutingFormResponseDenormalized[]
expenseLogs CreditExpenseLog[]
report BookingReport?
wrongAssignmentReports WrongAssignmentReport[]
routingTrace RoutingTrace?
// @@partial_index([reassignById])
@@ -1332,6 +1335,7 @@ model App_RoutingForms_Form {
settings Json?
incompleteBookingActions App_RoutingForms_IncompleteBookingActions[]
workflows WorkflowsOnRoutingForms[]
wrongAssignmentReports WrongAssignmentReport[]
@@index([userId])
@@index([disabled])
@@ -2555,6 +2559,36 @@ model BookingReport {
@@index([createdAt])
}
enum WrongAssignmentReportStatus {
PENDING // Initial state - awaiting review
REVIEWED // Admin has seen it
RESOLVED // Corrective action taken
DISMISSED // Admin decided no action needed
}
model WrongAssignmentReport {
id String @id @default(uuid()) @db.Uuid
bookingUid String @unique
booking Booking @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
reportedById Int?
reportedBy User? @relation("WrongAssignmentReportedBy", fields: [reportedById], references: [id], onDelete: SetNull)
correctAssignee String?
additionalNotes String
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
routingFormId String?
routingForm App_RoutingForms_Form? @relation(fields: [routingFormId], references: [id], onDelete: SetNull)
status WrongAssignmentReportStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportedById])
@@index([teamId])
@@index([routingFormId])
@@index([status])
@@index([createdAt])
}
enum BillingPeriod {
MONTHLY
ANNUALLY
@@ -16,6 +16,7 @@ import { ZGetRoutingTraceInputSchema } from "./getRoutingTrace.schema";
import { ZReportBookingInputSchema } from "./reportBooking.schema";
import { ZReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
import { ZHasWrongAssignmentReportInputSchema } from "./hasWrongAssignmentReport.schema";
import { bookingsProcedure } from "./util";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
export const bookingsRouter = router({
@@ -129,6 +130,16 @@ export const bookingsRouter = router({
input,
});
}),
hasWrongAssignmentReport: authedProcedure
.input(ZHasWrongAssignmentReportInputSchema)
.query(async ({ input, ctx }) => {
const { hasWrongAssignmentReportHandler } = await import("./hasWrongAssignmentReport.handler");
return hasWrongAssignmentReportHandler({
ctx,
input,
});
}),
getBookingHistory: authedProcedure.input(ZGetBookingHistoryInputSchema).query(async ({ input, ctx }) => {
const { getBookingHistoryHandler } = await import("./getBookingHistory.handler");
@@ -0,0 +1,33 @@
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { WrongAssignmentReportRepository } from "@calcom/features/bookings/repositories/WrongAssignmentReportRepository";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { THasWrongAssignmentReportInputSchema } from "./hasWrongAssignmentReport.schema";
type HasWrongAssignmentReportOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: THasWrongAssignmentReportInputSchema;
};
export const hasWrongAssignmentReportHandler = async ({ ctx, input }: HasWrongAssignmentReportOptions) => {
const { user } = ctx;
const { bookingUid } = input;
const bookingAccessService = new BookingAccessService(prisma);
const hasAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: user.id,
bookingUid,
});
if (!hasAccess) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You don't have access to this booking" });
}
const repo = new WrongAssignmentReportRepository(prisma);
return { hasReport: await repo.existsByBookingUid(bookingUid) };
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZHasWrongAssignmentReportInputSchema = z.object({
bookingUid: z.string(),
});
export type THasWrongAssignmentReportInputSchema = z.infer<typeof ZHasWrongAssignmentReportInputSchema>;
@@ -1,4 +1,5 @@
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";
@@ -8,10 +9,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { reportWrongAssignmentHandler } from "./reportWrongAssignment.handler";
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/bookings/repositories/WrongAssignmentReportRepository");
vi.mock("@calcom/features/bookings/services/BookingAccessService");
vi.mock("@calcom/features/webhooks/lib/getWebhooks");
vi.mock("@calcom/features/webhooks/lib/sendPayload");
vi.mock("@calcom/prisma", () => ({ default: {} }));
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",
};
return translations[key] || key;
}),
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
@@ -49,6 +59,7 @@ describe("reportWrongAssignmentHandler", () => {
teamId: 5,
},
assignmentReason: [{ reasonString: "Matched by round-robin" }],
routedFromRoutingFormReponse: null,
};
const mockBookingRepo = {
@@ -59,12 +70,22 @@ describe("reportWrongAssignmentHandler", () => {
doesUserIdHaveAccessToBooking: vi.fn(),
};
const mockWrongAssignmentReportRepo = {
existsByBookingUid: vi.fn().mockResolvedValue(false),
createReport: vi.fn().mockResolvedValue({ id: "report-uuid-123" }),
};
beforeEach(() => {
vi.clearAllMocks();
mockWrongAssignmentReportRepo.existsByBookingUid.mockResolvedValue(false);
mockWrongAssignmentReportRepo.createReport.mockResolvedValue({ id: "report-uuid-123" });
vi.mocked(BookingRepository).mockImplementation(function () {
return mockBookingRepo;
});
vi.mocked(WrongAssignmentReportRepository).mockImplementation(function () {
return mockWrongAssignmentReportRepo;
});
vi.mocked(BookingAccessService).mockImplementation(function () {
return mockBookingAccessService;
});
@@ -117,6 +138,27 @@ describe("reportWrongAssignmentHandler", () => {
message: "Booking not found",
});
});
it("should throw BAD_REQUEST when a report already exists for the booking", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
mockWrongAssignmentReportRepo.existsByBookingUid.mockResolvedValue(true);
await expect(
reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
additionalNotes: "Duplicate report",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "A wrong assignment report has already been submitted for this booking",
});
expect(mockWrongAssignmentReportRepo.createReport).not.toHaveBeenCalled();
});
});
describe("successful report", () => {
@@ -241,6 +283,104 @@ describe("reportWrongAssignmentHandler", () => {
});
});
describe("database persistence", () => {
it("should persist report to database with all fields", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
await reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
correctAssignee: "correct@example.com",
additionalNotes: "This booking should have gone to the sales team",
},
});
expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalledWith({
bookingUid: "test-booking-uid",
reportedById: mockUser.id,
correctAssignee: "correct@example.com",
additionalNotes: "This booking should have gone to the sales team",
teamId: mockBooking.eventType.teamId,
routingFormId: null,
});
});
it("should persist report with null optional fields", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({
...mockBooking,
eventType: null,
});
await reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
additionalNotes: "Wrong person",
},
});
expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalledWith({
bookingUid: "test-booking-uid",
reportedById: mockUser.id,
correctAssignee: null,
additionalNotes: "Wrong person",
teamId: null,
routingFormId: null,
});
});
it("should return reportId in response", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
const result = await reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
additionalNotes: "Notes",
},
});
expect(result.reportId).toBe("report-uuid-123");
});
it("should throw if database write fails", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
mockWrongAssignmentReportRepo.createReport.mockRejectedValue(new Error("Database error"));
await expect(
reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
additionalNotes: "Notes",
},
})
).rejects.toThrow("Database error");
});
it("should persist report even when webhooks fail", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
vi.mocked(getWebhooks).mockRejectedValue(new Error("Webhook service down"));
const result = await reportWrongAssignmentHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
additionalNotes: "Notes",
},
});
expect(mockWrongAssignmentReportRepo.createReport).toHaveBeenCalled();
expect(result.success).toBe(true);
});
});
describe("edge cases", () => {
it("should handle booking without assignmentReason", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
@@ -1,9 +1,12 @@
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 prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -24,7 +27,9 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs
const { user } = ctx;
const { bookingUid, correctAssignee, additionalNotes } = input;
const t = await getTranslation(user.locale ?? "en", "common");
const bookingRepo = new BookingRepository(prisma);
const wrongAssignmentReportRepo = new WrongAssignmentReportRepository(prisma);
const bookingAccessService = new BookingAccessService(prisma);
const hasAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({
@@ -49,6 +54,36 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs
const guestEmail = booking.attendees[0]?.email || "";
const hostEmail = booking.user?.email || "";
const hostName = booking.user?.name || 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: {
@@ -118,5 +153,6 @@ export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAs
return {
success: true,
message: "Wrong assignment reported successfully",
reportId: report.id,
};
};