Skip to content

Commit c050499

Browse files
feat: Write wrong assignment reports to the database (calcom#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 calcom#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>
1 parent c3a8301 commit c050499

12 files changed

Lines changed: 513 additions & 2 deletions

File tree

apps/web/components/dialog/WrongAssignmentDialog.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function AssigneeSection(props: AssigneeSectionProps): JSX.Element {
164164
field: ControllerRenderProps<FormValues, "correctAssignee">;
165165
}): JSX.Element => {
166166
const handleChange = (option: TeamMemberOption | null): void => {
167-
if (option) field.onChange(option.value);
167+
field.onChange(option ? option.value : "");
168168
};
169169
return (
170170
<Select
@@ -234,6 +234,12 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
234234
},
235235
});
236236

237+
const { data: existingReport, isPending: isCheckingReport } = trpc.viewer.bookings.hasWrongAssignmentReport.useQuery(
238+
{ bookingUid },
239+
{ enabled: isOpenDialog }
240+
);
241+
const alreadyReported = existingReport?.hasReport ?? false;
242+
237243
const teamIdForQuery = teamId ?? 0;
238244
const { data: teamMembersData } = trpc.viewer.teams.listMembers.useQuery(
239245
{ teamId: teamIdForQuery, limit: 100 },
@@ -311,6 +317,10 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
311317
errorMessage={errors.additionalNotes?.message}
312318
/>
313319

320+
{alreadyReported && (
321+
<Alert severity="warning" message={t("wrong_assignment_already_reported")} className="mb-4" />
322+
)}
323+
314324
<Alert severity="info" title={t("did_you_know")} message={t("wrong_assignment_crm_info")} />
315325
</div>
316326
</div>
@@ -319,7 +329,7 @@ export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Elemen
319329
<Button type="button" color="secondary" onClick={handleCloseClick} disabled={isPending}>
320330
{t("close")}
321331
</Button>
322-
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
332+
<Button type="submit" color="primary" disabled={isPending || alreadyReported || isCheckingReport} loading={isPending || isCheckingReport}>
323333
{t("submit")}
324334
</Button>
325335
</DialogFooter>

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,7 @@
840840
"report_wrong_assignment": "Report wrong assignment",
841841
"wrong_assignment": "Wrong Assignment",
842842
"wrong_assignment_reported": "Wrong assignment reported successfully",
843+
"wrong_assignment_already_reported": "A wrong assignment report has already been submitted for this booking.",
843844
"routing_reason": "Routing Reason",
844845
"no_routing_reason": "No routing reason available",
845846
"who_booked_it": "Who booked it?",

packages/features/bookings/repositories/BookingRepository.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2162,6 +2162,11 @@ export class BookingRepository implements IBookingRepository {
21622162
reasonEnum: true,
21632163
},
21642164
},
2165+
routedFromRoutingFormReponse: {
2166+
select: {
2167+
formId: true,
2168+
},
2169+
},
21652170
},
21662171
});
21672172
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
import type { PrismaClient } from "@calcom/prisma";
4+
5+
import { WrongAssignmentReportRepository } from "./WrongAssignmentReportRepository";
6+
7+
describe("WrongAssignmentReportRepository", () => {
8+
let repository: WrongAssignmentReportRepository;
9+
10+
const mockPrisma = {
11+
wrongAssignmentReport: {
12+
findUnique: vi.fn(),
13+
create: vi.fn(),
14+
},
15+
} as unknown as PrismaClient;
16+
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
repository = new WrongAssignmentReportRepository(mockPrisma);
20+
});
21+
22+
describe("existsByBookingUid", () => {
23+
it("should return true when a report exists for the booking", async () => {
24+
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue({ id: "report-123" });
25+
26+
const result = await repository.existsByBookingUid("test-booking-uid");
27+
28+
expect(result).toBe(true);
29+
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
30+
where: { bookingUid: "test-booking-uid" },
31+
select: { id: true },
32+
});
33+
});
34+
35+
it("should return false when no report exists for the booking", async () => {
36+
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue(null);
37+
38+
const result = await repository.existsByBookingUid("non-existent-booking-uid");
39+
40+
expect(result).toBe(false);
41+
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
42+
where: { bookingUid: "non-existent-booking-uid" },
43+
select: { id: true },
44+
});
45+
});
46+
47+
it("should handle empty string bookingUid", async () => {
48+
mockPrisma.wrongAssignmentReport.findUnique.mockResolvedValue(null);
49+
50+
const result = await repository.existsByBookingUid("");
51+
52+
expect(result).toBe(false);
53+
expect(mockPrisma.wrongAssignmentReport.findUnique).toHaveBeenCalledWith({
54+
where: { bookingUid: "" },
55+
select: { id: true },
56+
});
57+
});
58+
});
59+
60+
describe("createReport", () => {
61+
it("should create a report with all fields", async () => {
62+
const input = {
63+
bookingUid: "test-booking-uid",
64+
reportedById: 1,
65+
correctAssignee: "correct@example.com",
66+
additionalNotes: "This booking was assigned incorrectly",
67+
teamId: 5,
68+
routingFormId: "routing-form-123",
69+
};
70+
71+
const mockCreatedReport = { id: "report-uuid-123" };
72+
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
73+
74+
const result = await repository.createReport(input);
75+
76+
expect(result).toEqual({ id: "report-uuid-123" });
77+
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
78+
data: input,
79+
select: { id: true },
80+
});
81+
});
82+
83+
it("should create a report with null optional fields", async () => {
84+
const input = {
85+
bookingUid: "test-booking-uid-2",
86+
reportedById: 2,
87+
correctAssignee: null,
88+
additionalNotes: "Wrong person",
89+
teamId: null,
90+
routingFormId: null,
91+
};
92+
93+
const mockCreatedReport = { id: "report-uuid-456" };
94+
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
95+
96+
const result = await repository.createReport(input);
97+
98+
expect(result).toEqual({ id: "report-uuid-456" });
99+
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
100+
data: input,
101+
select: { id: true },
102+
});
103+
});
104+
105+
it("should create a report with empty additionalNotes", async () => {
106+
const input = {
107+
bookingUid: "test-booking-uid-3",
108+
reportedById: 3,
109+
correctAssignee: "someone@example.com",
110+
additionalNotes: "",
111+
teamId: 10,
112+
routingFormId: "routing-form-456",
113+
};
114+
115+
const mockCreatedReport = { id: "report-uuid-789" };
116+
mockPrisma.wrongAssignmentReport.create.mockResolvedValue(mockCreatedReport);
117+
118+
const result = await repository.createReport(input);
119+
120+
expect(result).toEqual({ id: "report-uuid-789" });
121+
expect(mockPrisma.wrongAssignmentReport.create).toHaveBeenCalledWith({
122+
data: input,
123+
select: { id: true },
124+
});
125+
});
126+
127+
it("should propagate database errors", async () => {
128+
const input = {
129+
bookingUid: "test-booking-uid",
130+
reportedById: 1,
131+
correctAssignee: null,
132+
additionalNotes: "Notes",
133+
teamId: null,
134+
routingFormId: null,
135+
};
136+
137+
mockPrisma.wrongAssignmentReport.create.mockRejectedValue(new Error("Database connection failed"));
138+
139+
await expect(repository.createReport(input)).rejects.toThrow("Database connection failed");
140+
});
141+
142+
it("should propagate foreign key constraint errors", async () => {
143+
const input = {
144+
bookingUid: "non-existent-booking",
145+
reportedById: 999999,
146+
correctAssignee: null,
147+
additionalNotes: "Notes",
148+
teamId: null,
149+
routingFormId: null,
150+
};
151+
152+
mockPrisma.wrongAssignmentReport.create.mockRejectedValue(
153+
new Error("Foreign key constraint failed on the field: `bookingUid`")
154+
);
155+
156+
await expect(repository.createReport(input)).rejects.toThrow("Foreign key constraint failed");
157+
});
158+
});
159+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
export class WrongAssignmentReportRepository {
4+
constructor(private readonly prismaClient: PrismaClient) {}
5+
6+
async existsByBookingUid(bookingUid: string): Promise<boolean> {
7+
const report = await this.prismaClient.wrongAssignmentReport.findUnique({
8+
where: { bookingUid },
9+
select: { id: true },
10+
});
11+
return !!report;
12+
}
13+
14+
async createReport(input: {
15+
bookingUid: string;
16+
reportedById: number;
17+
correctAssignee: string | null;
18+
additionalNotes: string;
19+
teamId: number | null;
20+
routingFormId: string | null;
21+
}): Promise<{ id: string }> {
22+
return this.prismaClient.wrongAssignmentReport.create({
23+
data: input,
24+
select: { id: true },
25+
});
26+
}
27+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
-- CreateEnum
2+
CREATE TYPE "public"."WrongAssignmentReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'RESOLVED', 'DISMISSED');
3+
4+
-- CreateTable
5+
CREATE TABLE "public"."WrongAssignmentReport" (
6+
"id" UUID NOT NULL,
7+
"bookingUid" TEXT NOT NULL,
8+
"reportedById" INTEGER,
9+
"correctAssignee" TEXT,
10+
"additionalNotes" TEXT NOT NULL,
11+
"teamId" INTEGER,
12+
"routingFormId" TEXT,
13+
"status" "public"."WrongAssignmentReportStatus" NOT NULL DEFAULT 'PENDING',
14+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
"updatedAt" TIMESTAMP(3) NOT NULL,
16+
17+
CONSTRAINT "WrongAssignmentReport_pkey" PRIMARY KEY ("id")
18+
);
19+
20+
-- CreateIndex
21+
CREATE UNIQUE INDEX "WrongAssignmentReport_bookingUid_key" ON "public"."WrongAssignmentReport"("bookingUid");
22+
23+
-- CreateIndex
24+
CREATE INDEX "WrongAssignmentReport_reportedById_idx" ON "public"."WrongAssignmentReport"("reportedById");
25+
26+
-- CreateIndex
27+
CREATE INDEX "WrongAssignmentReport_teamId_idx" ON "public"."WrongAssignmentReport"("teamId");
28+
29+
-- CreateIndex
30+
CREATE INDEX "WrongAssignmentReport_routingFormId_idx" ON "public"."WrongAssignmentReport"("routingFormId");
31+
32+
-- CreateIndex
33+
CREATE INDEX "WrongAssignmentReport_status_idx" ON "public"."WrongAssignmentReport"("status");
34+
35+
-- CreateIndex
36+
CREATE INDEX "WrongAssignmentReport_createdAt_idx" ON "public"."WrongAssignmentReport"("createdAt");
37+
38+
-- AddForeignKey
39+
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
40+
41+
-- AddForeignKey
42+
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
43+
44+
-- AddForeignKey
45+
ALTER TABLE "public"."WrongAssignmentReport" ADD CONSTRAINT "WrongAssignmentReport_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
46+
47+
-- AddForeignKey
48+
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;

packages/prisma/schema.prisma

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ model User {
498498
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
499499
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
500500
reportedBookings BookingReport[] @relation("ReportedBy")
501+
wrongAssignmentReports WrongAssignmentReport[] @relation("WrongAssignmentReportedBy")
501502
BookingInternalNote BookingInternalNote[]
502503
creationSource CreationSource?
503504
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
@@ -624,6 +625,7 @@ model Team {
624625
calAiPhoneNumbers CalAiPhoneNumber[]
625626
agents Agent[]
626627
bookingReports BookingReport[]
628+
wrongAssignmentReports WrongAssignmentReport[]
627629
628630
features TeamFeatures[]
629631
@@ -923,6 +925,7 @@ model Booking {
923925
routingFormResponses RoutingFormResponseDenormalized[]
924926
expenseLogs CreditExpenseLog[]
925927
report BookingReport?
928+
wrongAssignmentReports WrongAssignmentReport[]
926929
routingTrace RoutingTrace?
927930
928931
// @@partial_index([reassignById])
@@ -1332,6 +1335,7 @@ model App_RoutingForms_Form {
13321335
settings Json?
13331336
incompleteBookingActions App_RoutingForms_IncompleteBookingActions[]
13341337
workflows WorkflowsOnRoutingForms[]
1338+
wrongAssignmentReports WrongAssignmentReport[]
13351339
13361340
@@index([userId])
13371341
@@index([disabled])
@@ -2555,6 +2559,36 @@ model BookingReport {
25552559
@@index([createdAt])
25562560
}
25572561

2562+
enum WrongAssignmentReportStatus {
2563+
PENDING // Initial state - awaiting review
2564+
REVIEWED // Admin has seen it
2565+
RESOLVED // Corrective action taken
2566+
DISMISSED // Admin decided no action needed
2567+
}
2568+
2569+
model WrongAssignmentReport {
2570+
id String @id @default(uuid()) @db.Uuid
2571+
bookingUid String @unique
2572+
booking Booking @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
2573+
reportedById Int?
2574+
reportedBy User? @relation("WrongAssignmentReportedBy", fields: [reportedById], references: [id], onDelete: SetNull)
2575+
correctAssignee String?
2576+
additionalNotes String
2577+
teamId Int?
2578+
team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
2579+
routingFormId String?
2580+
routingForm App_RoutingForms_Form? @relation(fields: [routingFormId], references: [id], onDelete: SetNull)
2581+
status WrongAssignmentReportStatus @default(PENDING)
2582+
createdAt DateTime @default(now())
2583+
updatedAt DateTime @updatedAt
2584+
2585+
@@index([reportedById])
2586+
@@index([teamId])
2587+
@@index([routingFormId])
2588+
@@index([status])
2589+
@@index([createdAt])
2590+
}
2591+
25582592
enum BillingPeriod {
25592593
MONTHLY
25602594
ANNUALLY

packages/trpc/server/routers/viewer/bookings/_router.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ZGetRoutingTraceInputSchema } from "./getRoutingTrace.schema";
1616
import { ZReportBookingInputSchema } from "./reportBooking.schema";
1717
import { ZReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema";
1818
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
19+
import { ZHasWrongAssignmentReportInputSchema } from "./hasWrongAssignmentReport.schema";
1920
import { bookingsProcedure } from "./util";
2021
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
2122
export const bookingsRouter = router({
@@ -129,6 +130,16 @@ export const bookingsRouter = router({
129130
input,
130131
});
131132
}),
133+
hasWrongAssignmentReport: authedProcedure
134+
.input(ZHasWrongAssignmentReportInputSchema)
135+
.query(async ({ input, ctx }) => {
136+
const { hasWrongAssignmentReportHandler } = await import("./hasWrongAssignmentReport.handler");
137+
138+
return hasWrongAssignmentReportHandler({
139+
ctx,
140+
input,
141+
});
142+
}),
132143
getBookingHistory: authedProcedure.input(ZGetBookingHistoryInputSchema).query(async ({ input, ctx }) => {
133144
const { getBookingHistoryHandler } = await import("./getBookingHistory.handler");
134145

0 commit comments

Comments
 (0)