From c7c8564b63d5def87607a4e2b67fe338e839f44e Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 28 Oct 2025 11:34:56 +0100 Subject: [PATCH] fix(insights): convert string filter values to numbers for integer columns (#24730) ## What does this PR do? Fixes PostgreSQL error "operator does not exist: integer = text" when filtering by `bookingStatusOrder` and `bookingUserId` fields. Frontend sends filter values as strings, but these database columns are integers requiring type conversion. Changes: - Convert `bookingStatusOrder` filter values from strings to numbers - Convert `bookingUserId` filter values from strings to numbers - Update tests to use numeric values for `bookingStatusOrder` filters - Add test for string-to-number conversion in `bookingUserId` filter - Fix `Prisma.Sql` composition in 3 integration tests ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] N/A - I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. --- ## Summary by cubic Converts string filter values to numbers for integer columns in Insights routing to prevent PostgreSQL type errors. Fixes filtering by bookingStatusOrder and bookingUserId when the frontend sends strings. - **Bug Fixes** - Cast bookingStatusOrder and bookingUserId filter values to numbers and use = ANY([...]). - Update tests to expect numeric arrays; add test for string-to-number conversion on bookingUserId. - Correct Prisma.sql composition in three integration tests before calling $queryRaw. --- .../services/InsightsRoutingBaseService.ts | 17 ++++- ...InsightsRoutingService.integration-test.ts | 67 +++++++++++++++---- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/features/insights/services/InsightsRoutingBaseService.ts b/packages/features/insights/services/InsightsRoutingBaseService.ts index a2680a71e0..b5a109945d 100644 --- a/packages/features/insights/services/InsightsRoutingBaseService.ts +++ b/packages/features/insights/services/InsightsRoutingBaseService.ts @@ -710,7 +710,12 @@ export class InsightsRoutingBaseService { // Extract booking status order filter const bookingStatusOrder = filtersMap["bookingStatusOrder"]; if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) { - const statusCondition = makeSqlCondition(bookingStatusOrder.value); + // Convert string values to numbers for integer column + const integerFilterValue = { + ...bookingStatusOrder.value, + data: bookingStatusOrder.value.data.map((order) => Number(order)), + }; + const statusCondition = makeSqlCondition(integerFilterValue); if (statusCondition) { conditions.push(Prisma.sql`rfrd."bookingStatusOrder" ${statusCondition}`); } @@ -764,7 +769,15 @@ export class InsightsRoutingBaseService { // Extract member user IDs filter (multi-select) const memberUserIds = filtersMap["bookingUserId"]; if (memberUserIds && isMultiSelectFilterValue(memberUserIds.value)) { - conditions.push(Prisma.sql`rfrd."bookingUserId" = ANY(${memberUserIds.value.data})`); + // Convert string values to numbers for integer column + const integerFilterValue = { + ...memberUserIds.value, + data: memberUserIds.value.data.map((id) => Number(id)), + }; + const userIdCondition = makeSqlCondition(integerFilterValue); + if (userIdCondition) { + conditions.push(Prisma.sql`rfrd."bookingUserId" ${userIdCondition}`); + } } // Extract form ID filter (single-select) diff --git a/packages/features/insights/services/InsightsRoutingService.integration-test.ts b/packages/features/insights/services/InsightsRoutingService.integration-test.ts index a00ecade30..97bec520e1 100644 --- a/packages/features/insights/services/InsightsRoutingService.integration-test.ts +++ b/packages/features/insights/services/InsightsRoutingService.integration-test.ts @@ -759,9 +759,10 @@ describe("InsightsRoutingService Integration Tests", () => { }); const baseConditions = await service.getBaseConditions(); - const results = await prisma.$queryRaw>` + const query = Prisma.sql` SELECT id FROM "RoutingFormResponseDenormalized" rfrd WHERE ${baseConditions} `; + const results = await prisma.$queryRaw>(query); // Should only return the authorized user's form response expect(results).toHaveLength(2); @@ -863,9 +864,10 @@ describe("InsightsRoutingService Integration Tests", () => { }); const baseConditions = await service.getBaseConditions(); - const results = await prisma.$queryRaw>` + const query = Prisma.sql` SELECT id FROM "RoutingFormResponseDenormalized" rfrd WHERE ${baseConditions} `; + const results = await prisma.$queryRaw>(query); // Should only return the authorized user's form response expect(results).toHaveLength(1); @@ -953,9 +955,10 @@ describe("InsightsRoutingService Integration Tests", () => { }); const baseConditions = await service.getBaseConditions(); - const results = await prisma.$queryRaw>` + const query = Prisma.sql` SELECT id FROM "RoutingFormResponseDenormalized" rfrd WHERE ${baseConditions} `; + const results = await prisma.$queryRaw>(query); // Should return both form responses (original user's and team member's) expect(results).toHaveLength(2); @@ -1070,7 +1073,7 @@ describe("InsightsRoutingService Integration Tests", () => { id: "bookingStatusOrder", value: { type: ColumnFilterType.MULTI_SELECT, - data: ["pending", "accepted"], + data: ["2", "1"], // String values that will be converted to numbers }, }, ], @@ -1081,7 +1084,7 @@ describe("InsightsRoutingService Integration Tests", () => { expect(filterConditions).toEqual( Prisma.sql`(rfrd."createdAt" >= ${defaultFilters.startDate}::timestamp AND rfrd."createdAt" <= ${ defaultFilters.endDate - }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${["pending", "accepted"]}))` + }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${[2, 1]}))` ); await testData.cleanup(); @@ -1204,6 +1207,45 @@ describe("InsightsRoutingService Integration Tests", () => { await testData.cleanup(); }); + it("should filter by member user IDs with string values (multi-select)", async () => { + const testData = await createTestData({ + teamRole: MembershipRole.OWNER, + orgRole: MembershipRole.OWNER, + }); + + const defaultFilters = createDefaultFilters(); + const service = new InsightsRoutingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + teamId: undefined, + }, + filters: { + ...defaultFilters, + columnFilters: [ + { + id: "bookingUserId", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: [String(testData.user.id), "999"], // String values that will be converted to numbers + }, + }, + ], + }, + }); + + const filterConditions = await service.getFilterConditions(); + expect(filterConditions).toEqual( + Prisma.sql`(rfrd."createdAt" >= ${defaultFilters.startDate}::timestamp AND rfrd."createdAt" <= ${ + defaultFilters.endDate + }::timestamp) AND (rfrd."bookingUserId" = ANY(${[testData.user.id, 999]}))` + ); + + await testData.cleanup(); + }); + it("should filter by attendee name (text)", async () => { const testData = await createTestData({ teamRole: MembershipRole.OWNER, @@ -1450,7 +1492,7 @@ describe("InsightsRoutingService Integration Tests", () => { id: "bookingStatusOrder", value: { type: ColumnFilterType.MULTI_SELECT, - data: ["pending"], + data: ["2"], // String value that will be converted to number (2 = PENDING) }, }, { @@ -1476,7 +1518,7 @@ describe("InsightsRoutingService Integration Tests", () => { Prisma.sql`(((rfrd."createdAt" >= ${defaultFilters.startDate}::timestamp AND rfrd."createdAt" <= ${ defaultFilters.endDate }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${[ - "pending", + 2, ]}))) AND (rfrd."bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf WHERE rrf."responseId" = rfrd."id" @@ -1556,7 +1598,7 @@ describe("InsightsRoutingService Integration Tests", () => { id: "bookingStatusOrder", value: { type: ColumnFilterType.MULTI_SELECT, - data: ["pending", "accepted"], + data: ["2", "1"], // String values that will be converted to numbers (2=PENDING, 1=ACCEPTED) }, }, ], @@ -1567,10 +1609,7 @@ describe("InsightsRoutingService Integration Tests", () => { expect(filterConditions).toEqual( Prisma.sql`((rfrd."createdAt" >= ${defaultFilters.startDate}::timestamp AND rfrd."createdAt" <= ${ defaultFilters.endDate - }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${[ - "pending", - "accepted", - ]}))) AND (rfrd."formId" = ${"form-456"})` + }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${[2, 1]}))) AND (rfrd."formId" = ${"form-456"})` ); await testData.cleanup(); @@ -1600,7 +1639,7 @@ describe("InsightsRoutingService Integration Tests", () => { id: "bookingStatusOrder", // System filter value: { type: ColumnFilterType.MULTI_SELECT, - data: ["pending"], + data: ["2"], // String value that will be converted to number (2 = PENDING) }, }, { @@ -1618,7 +1657,7 @@ describe("InsightsRoutingService Integration Tests", () => { expect(filterConditions).toEqual( Prisma.sql`((rfrd."createdAt" >= ${defaultFilters.startDate}::timestamp AND rfrd."createdAt" <= ${ defaultFilters.endDate - }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS ( + }::timestamp) AND (rfrd."bookingStatusOrder" = ANY(${[2]}))) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf WHERE rrf."responseId" = rfrd."id" AND rrf."fieldId" = ${customFieldId}