diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b846f63e24..7681a56ac6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3472,5 +3472,6 @@ "webhook_metadata": "Metadata", "stats": "Stats", "booking_status": "Booking status", + "phone": "Phone", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/insights/components/BookedByCell.tsx b/packages/features/insights/components/BookedByCell.tsx index cec9143164..f5f2eb371d 100644 --- a/packages/features/insights/components/BookedByCell.tsx +++ b/packages/features/insights/components/BookedByCell.tsx @@ -1,6 +1,8 @@ import { useId } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Badge } from "@calcom/ui/components/badge"; +import { Tooltip } from "@calcom/ui/components/tooltip"; import type { RoutingFormTableRow } from "../lib/types"; import { CellWithOverflowX } from "./CellWithOverflowX"; @@ -9,21 +11,39 @@ export function BookedByCell({ attendees, rowId, }: { - attendees: RoutingFormTableRow["bookingAttendees"] | undefined; + attendees: RoutingFormTableRow["bookingAttendees"]; rowId: number; }) { const cellId = useId(); + const { t } = useLocale(); if (!attendees || attendees.length === 0) return
; return (
- {attendees.map((attendee) => ( - - - {attendee.name} - - - ))} + {attendees.map((attendee) => { + const tooltipContent = ( +
+
+ {t("email")}: {attendee.email} +
+ {attendee.phoneNumber && ( +
+ {t("phone")}: {attendee.phoneNumber} +
+ )} +
+ ); + + return ( + + + + {attendee.name} + + + + ); + })}
); } diff --git a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx index 2f242bbedd..164ebd4f4b 100644 --- a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx @@ -16,6 +16,7 @@ import { convertMapToFacetedValues, type FilterableColumn, } from "@calcom/features/data-table"; +import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters"; import { trpc } from "@calcom/trpc"; import { RoutingFormResponsesDownload } from "../../filters/Download"; @@ -35,8 +36,7 @@ const createdAtColumn: Extract { if (!isHeadersSuccess || !data) return []; return data.data as RoutingFormTableRow[]; @@ -88,6 +81,9 @@ export function RoutingFormResponsesTable() { columnVisibility: { formId: false, bookingUserId: false, + attendeeName: false, + attendeeEmail: false, + attendeePhone: false, utm_source: false, utm_medium: false, utm_campaign: false, diff --git a/packages/features/insights/components/routing/RoutingKPICards.tsx b/packages/features/insights/components/routing/RoutingKPICards.tsx index 536c9bb3d5..aca0c85477 100644 --- a/packages/features/insights/components/routing/RoutingKPICards.tsx +++ b/packages/features/insights/components/routing/RoutingKPICards.tsx @@ -1,37 +1,24 @@ "use client"; +import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import classNames from "@calcom/ui/classNames"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { useInsightsParameters } from "../../hooks/useInsightsParameters"; import { valueFormatter } from "../../lib"; import { ChartCard } from "../ChartCard"; export const RoutingKPICards = () => { const { t } = useLocale(); - const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } = - useInsightsParameters(); + const insightsRoutingParameters = useInsightsRoutingParameters(); - const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery( - { - teamId, - startDate, - endDate, - userId, - memberUserIds, - isAll, - routingFormId, - columnFilters, + const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); + }); const categories: { title: string; diff --git a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx index 118de2acd9..f76a2b0124 100644 --- a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx +++ b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import dayjs from "@calcom/dayjs"; import type { SortingState } from "@calcom/features/data-table"; +import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; @@ -15,8 +16,6 @@ import { } from "@calcom/ui/components/dropdown"; import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; -import { useInsightsParameters } from "../../hooks/useInsightsParameters"; - type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number]; type Props = { @@ -27,9 +26,9 @@ const BATCH_SIZE = 100; // Increased batch size for downloads export const RoutingFormResponsesDownload = ({ sorting }: Props) => { const { t } = useLocale(); - const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } = - useInsightsParameters(); const [isDownloading, setIsDownloading] = useState(false); + const insightsRoutingParameters = useInsightsRoutingParameters(); + const { startDate, endDate } = insightsRoutingParameters; const utils = trpc.useUtils(); @@ -40,14 +39,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => { total: number; }> => { const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({ - teamId, - startDate, - endDate, - userId, - memberUserIds, - isAll: isAll, - routingFormId, - columnFilters, + ...insightsRoutingParameters, sorting, limit: BATCH_SIZE, offset, diff --git a/packages/features/insights/hooks/useInsightsColumns.tsx b/packages/features/insights/hooks/useInsightsColumns.tsx index 6bfcc86753..49b4760f60 100644 --- a/packages/features/insights/hooks/useInsightsColumns.tsx +++ b/packages/features/insights/hooks/useInsightsColumns.tsx @@ -103,19 +103,53 @@ export const useInsightsColumns = ({ size: 200, enableColumnFilter: false, enableSorting: false, - meta: { - filter: { - type: ColumnFilterType.TEXT, - textOptions: { - placeholder: t("name_or_email"), - }, - }, - }, cell: (info) => { return ; }, }), + // Invisible attendee name filter + columnHelper.accessor("bookingAttendees", { + id: "attendeeName", + header: t("attendee_name"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + cell: () => null, + }), + + // Invisible attendee email filter + columnHelper.accessor("bookingAttendees", { + id: "attendeeEmail", + header: t("attendee_email_variable"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + cell: () => null, + }), + + // Invisible attendee phone filter + columnHelper.accessor("bookingAttendees", { + id: "attendeePhone", + header: t("attendee_phone_number"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + cell: () => null, + }), + ...((headers || []).map((fieldHeader) => { const isText = [ RoutingFormFieldType.TEXT, @@ -145,19 +179,28 @@ export const useInsightsColumns = ({ return acc; }, {} as Record) ?? {}; - return columnHelper.accessor(`response.${fieldHeader.id}`, { + return columnHelper.accessor((row) => row.fields.find((field) => field.fieldId === fieldHeader.id), { id: fieldHeader.id, header: startCase(fieldHeader.label), enableSorting: false, cell: (info) => { - const values = info.getValue(); + let values; + + if (isMultiSelect) { + values = info.getValue()?.valueStringArray; + } else if (isNumber) { + values = info.getValue()?.valueNumber; + } else { + values = info.getValue()?.valueString; + } + if (isMultiSelect || isSingleSelect) { const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values); return ( result.success && ( ) @@ -167,7 +210,7 @@ export const useInsightsColumns = ({ return ( result.success && (
- {result.data.value} + {result.data}
) ); diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts index 1530215fbb..b46285e8b0 100644 --- a/packages/features/insights/hooks/useInsightsParameters.ts +++ b/packages/features/insights/hooks/useInsightsParameters.ts @@ -51,7 +51,7 @@ export function useInsightsParameters() { }, [createdAtRange?.preset]); const columnFilters = useColumnFilters({ - exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"], + exclude: ["createdAt"], }); return { diff --git a/packages/features/insights/lib/types.ts b/packages/features/insights/lib/types.ts index a9451d972f..78868b5199 100644 --- a/packages/features/insights/lib/types.ts +++ b/packages/features/insights/lib/types.ts @@ -6,25 +6,13 @@ export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFo export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number]; -export const ZResponseMultipleValues = z.object({ - label: z.string(), - value: z.array(z.string()), -}); +export const ZResponseMultipleValues = z.array(z.string()); -export const ZResponseSingleValue = z.object({ - label: z.string(), - value: z.string(), -}); +export const ZResponseSingleValue = z.string(); -export const ZResponseTextValue = z.object({ - label: z.string(), - value: z.string(), -}); +export const ZResponseTextValue = z.string(); -export const ZResponseNumericValue = z.object({ - label: z.string(), - value: z.number(), -}); +export const ZResponseNumericValue = z.number(); export const ZResponseValue = z.union([ ZResponseMultipleValues, diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index 52aacce049..cb7e5fbb81 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -39,9 +39,19 @@ export type RoutingFormStatsInput = { sorting?: Sorting[]; }; -export type RoutingFormResponsesInput = RoutingFormStatsInput & { - offset?: number; - limit?: number; +export type InsightsRoutingServiceInput = { + scope: "user" | "team" | "org"; + selectedTeamId?: number; + + columnFilters?: ColumnFilter[]; + startDate: string; + endDate: string; +}; + +export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & { + sorting?: Sorting[]; + offset: number; + limit: number; }; export const routingFormStatsInputSchema = z.object({ @@ -56,11 +66,20 @@ export const routingFormStatsInputSchema = z.object({ sorting: z.array(ZSorting).optional(), }) satisfies z.ZodType; -export const routingFormResponsesInputSchema = z.object({ - ...routingFormStatsInputSchema.shape, - offset: z.number().optional(), - limit: z.number().max(100).optional(), -}) satisfies z.ZodType; +export const insightsRoutingServiceInputSchema = z.object({ + scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), + selectedTeamId: z.number().optional(), + columnFilters: z.array(ZColumnFilter).optional(), + startDate: z.string(), + endDate: z.string(), +}) satisfies z.ZodType; + +export const insightsRoutingServicePaginatedInputSchema = z.object({ + ...insightsRoutingServiceInputSchema.shape, + offset: z.number(), + limit: z.number().max(100), + sorting: z.array(ZSorting).optional(), +}) satisfies z.ZodType; export const routingRepositoryBaseInputSchema = z.object({ scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index 95906e70be..4b00c39ab4 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -10,17 +10,10 @@ import { } from "@calcom/app-store/routing-forms/lib/FieldTypes"; import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod"; import dayjs from "@calcom/dayjs"; -import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server"; -import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types"; -import type { - RoutingFormResponsesInput, - RoutingFormStatsInput, -} from "@calcom/features/insights/server/raw-data.schema"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService"; import { readonlyPrisma as prisma } from "@calcom/prisma"; -import { type ResponseValue, ZResponse } from "../lib/types"; - type RoutingFormInsightsTeamFilter = { userId?: number | null; teamId?: number | null; @@ -29,14 +22,6 @@ type RoutingFormInsightsTeamFilter = { routingFormId?: string | null; }; -type RoutingFormStatsFilter = RoutingFormStatsInput & { - organizationId: number | null; -}; - -type RoutingFormResponsesFilter = RoutingFormResponsesInput & { - organizationId: number | null; -}; - type WhereForTeamOrAllTeams = Pick; class RoutingEventsInsights { @@ -100,57 +85,6 @@ class RoutingEventsInsights { return formsWhereCondition; } - static async getRoutingFormStats({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - userId, - memberUserIds, - columnFilters, - sorting, - }: RoutingFormStatsFilter) { - const whereClause = await this.getWhereClauseForRoutingFormResponses({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - userId, - memberUserIds, - columnFilters, - sorting, - }); - - if (whereClause.bookingUid) { - // If bookingUid filter is applied, total count should be either 0 or 1. - // So this metrics doesn't provide any value. - return null; - } - - const totalPromise = prisma.routingFormResponse.count({ - where: whereClause, - }); - - const totalWithoutBookingPromise = prisma.routingFormResponse.count({ - where: { - ...whereClause, - bookingUid: null, - }, - }); - - const [total, totalWithoutBooking] = await Promise.all([totalPromise, totalWithoutBookingPromise]); - - return { - total, - totalWithoutBooking, - totalWithBooking: total - totalWithoutBooking, - }; - } - static async getRoutingFormsForFilters({ userId, teamId, @@ -183,250 +117,37 @@ class RoutingEventsInsights { }); } - static async getWhereClauseForRoutingFormResponses({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - userId, - memberUserIds, - columnFilters, - }: RoutingFormResponsesFilter) { - const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({ - userId, - teamId, - isAll, - organizationId, - routingFormId, - }); - - const getLowercasedFilterValue = (filter: TypedColumnFilter) => { - if (filter.value.type === ColumnFilterType.TEXT) { - return { - ...filter.value, - data: { - ...filter.value.data, - operand: filter.value.data.operand.toLowerCase(), - }, - }; - } - return filter.value; - }; - - const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder"); - const bookingAssignmentReason = columnFilters.find( - (filter) => filter.id === "bookingAssignmentReason" - ) as TypedColumnFilter | undefined; - const assignmentReasonValue = bookingAssignmentReason - ? getLowercasedFilterValue(bookingAssignmentReason) - : undefined; - const bookingUid = columnFilters.find((filter) => filter.id === "bookingUid") as - | TypedColumnFilter - | undefined; - - const responseFilters = columnFilters.filter( - (filter) => - filter.id !== "bookingStatusOrder" && - filter.id !== "bookingAssignmentReason" && - filter.id !== "bookingUid" - ); - - const whereClause: Prisma.RoutingFormResponseWhereInput = { - ...(formsTeamWhereCondition.id !== undefined && { - formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">, - }), - ...(formsTeamWhereCondition.teamId !== undefined && { - formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">, - }), - ...(formsTeamWhereCondition.userId !== undefined && { - formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">, - }), - - // bookingStatus - ...(bookingStatusOrder && - makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })), - - // bookingAssignmentReason - ...(assignmentReasonValue && - makeWhereClause({ - columnName: "bookingAssignmentReasonLowercase", - filterValue: assignmentReasonValue, - })), - - // memberUserId - ...(memberUserIds && memberUserIds.length > 0 && { bookingUserId: { in: memberUserIds } }), - - // createdAt - ...(startDate && - endDate && { - createdAt: { - gte: dayjs(startDate).startOf("day").toDate(), - lte: dayjs(endDate).endOf("day").toDate(), - }, - }), - - // bookingUid - ...(bookingUid && - makeWhereClause({ - columnName: "bookingUid", - filterValue: bookingUid.value, - })), - - // AND clause - ...(responseFilters.length > 0 && { - AND: responseFilters.map((fieldFilter) => { - return makeWhereClause({ - columnName: "responseLowercase", - filterValue: getLowercasedFilterValue(fieldFilter), - json: { path: [fieldFilter.id, "value"] }, - }); - }), - }), - }; - - return whereClause; - } - - static async getRoutingFormPaginatedResponses({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - limit, - offset, - userId, - memberUserIds, - columnFilters, - sorting, - }: RoutingFormResponsesFilter) { - const whereClause = await this.getWhereClauseForRoutingFormResponses({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - userId, - memberUserIds, - columnFilters, - sorting, - }); - - const totalResponsePromise = prisma.routingFormResponse.count({ - where: whereClause, - }); - - const responsesPromise = prisma.routingFormResponse.findMany({ - select: { - id: true, - response: true, - formId: true, - formName: true, - bookingUid: true, - bookingStatus: true, - bookingStatusOrder: true, - bookingCreatedAt: true, - bookingAttendees: true, - bookingUserId: true, - bookingUserName: true, - bookingUserEmail: true, - bookingUserAvatarUrl: true, - bookingAssignmentReason: true, - bookingStartTime: true, - bookingEndTime: true, - createdAt: true, - utm_source: true, - utm_medium: true, - utm_campaign: true, - utm_term: true, - utm_content: true, - }, - where: whereClause, - orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" }, - take: limit, - skip: offset, - }); - - const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]); - - type Response = Omit< - (typeof responses)[number], - "response" | "responseLowercase" | "bookingAttendees" - > & { - response: Record; - responseLowercase: Record; - bookingAttendees?: { name: string; email: string; timeZone: string }[]; - }; - - return { - total: totalResponses, - data: responses as Response[], - }; - } - static async getRoutingFormPaginatedResponsesForDownload({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - limit, - offset, - userId, - memberUserIds, - columnFilters, - sorting, - }: RoutingFormResponsesFilter) { - const headersPromise = this.getRoutingFormHeaders({ - userId, - teamId, - isAll, - organizationId, - routingFormId, - }); - const dataPromise = this.getRoutingFormPaginatedResponses({ - teamId, - startDate, - endDate, - isAll, - organizationId, - routingFormId, - userId, - memberUserIds, - limit, - offset, - columnFilters, - sorting, - }); - + headersPromise, + dataPromise, + }: { + headersPromise: ReturnType; + dataPromise: ReturnType; + }) { const [headers, data] = await Promise.all([headersPromise, dataPromise]); const dataWithFlatResponse = data.data.map((item) => { - const { bookingAttendees } = item; - const responseParseResult = ZResponse.safeParse(item.response); - const response = responseParseResult.success ? responseParseResult.data : {}; + const bookingAttendees = item.bookingAttendees || []; const fields = (headers || []).reduce((acc, header) => { const id = header.id; - if (!response[id]) { + const field = item.fields.find((field) => field.fieldId === id); + if (!field) { acc[header.label] = ""; return acc; } if (header.type === "select") { - acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label; - } else if (header.type === "multiselect" && Array.isArray(response[id].value)) { - acc[header.label] = (response[id].value as string[]) + acc[header.label] = header.options?.find((option) => option.id === field.valueString)?.label; + } else if (header.type === "multiselect" && Array.isArray(field.valueStringArray)) { + acc[header.label] = field.valueStringArray .map((value) => header.options?.find((option) => option.id === value)?.label) .filter((label): label is string => label !== undefined) .sort() .join(", "); + } else if (header.type === "number") { + acc[header.label] = field.valueNumber?.toString() || ""; } else { - acc[header.label] = response[id].value as string; + acc[header.label] = field.valueString || ""; } return acc; }, {} as Record); @@ -442,9 +163,6 @@ class RoutingEventsInsights { "Booking Created At": item.bookingCreatedAt?.toISOString() || "", "Booking Start Time": item.bookingStartTime?.toISOString() || "", "Booking End Time": item.bookingEndTime?.toISOString() || "", - "Attendee Name": (bookingAttendees as any)?.[0]?.name, - "Attendee Email": (bookingAttendees as any)?.[0]?.email, - "Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone, "Assignment Reason": item.bookingAssignmentReason || "", "Routed To Name": item.bookingUserName || "", "Routed To Email": item.bookingUserEmail || "", @@ -454,6 +172,12 @@ class RoutingEventsInsights { utm_campaign: item.utm_campaign || "", utm_term: item.utm_term || "", utm_content: item.utm_content || "", + ...((bookingAttendees || []) + .filter((attendee) => typeof attendee.name === "string" && typeof attendee.email === "string") + .reduce((acc, attendee, index) => { + acc[`Attendee ${index + 1}`] = `${attendee.name} (${attendee.email})`; + return acc; + }, {} as Record) || {}), }; }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 11d64db251..7384ae7dc0 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { rawDataInputSchema, - routingFormResponsesInputSchema, - routingFormStatsInputSchema, + insightsRoutingServiceInputSchema, + insightsRoutingServicePaginatedInputSchema, routingRepositoryBaseInputSchema, bookingRepositoryBaseInputSchema, } from "@calcom/features/insights/server/raw-data.schema"; @@ -827,55 +827,44 @@ export const insightsRouter = router({ }); }), routingFormsByStatus: userBelongsToTeamProcedure - .input(routingFormStatsInputSchema) + .input(insightsRoutingServiceInputSchema) .query(async ({ ctx, input }) => { - return await RoutingEventsInsights.getRoutingFormStats({ - teamId: input.teamId, - startDate: input.startDate, - endDate: input.endDate, - isAll: input.isAll, - organizationId: ctx.user.organizationId ?? null, - routingFormId: input.routingFormId, - userId: ctx.user.id, - memberUserIds: input.memberUserIds, - columnFilters: input.columnFilters, - sorting: input.sorting, - }); + const insightsRoutingService = createInsightsRoutingService(ctx, input); + return await insightsRoutingService.getRoutingFormStats(); }), routingFormResponses: userBelongsToTeamProcedure - .input(routingFormResponsesInputSchema) + .input(insightsRoutingServicePaginatedInputSchema) .query(async ({ ctx, input }) => { - return await RoutingEventsInsights.getRoutingFormPaginatedResponses({ - teamId: input.teamId, - startDate: input.startDate, - endDate: input.endDate, - isAll: input.isAll, - organizationId: ctx.user.organizationId ?? null, - routingFormId: input.routingFormId, - userId: ctx.user.id, - memberUserIds: input.memberUserIds, + const insightsRoutingService = createInsightsRoutingService(ctx, input); + return await insightsRoutingService.getTableData({ + sorting: input.sorting, limit: input.limit, offset: input.offset, - columnFilters: input.columnFilters, - sorting: input.sorting, }); }), routingFormResponsesForDownload: userBelongsToTeamProcedure - .input(routingFormResponsesInputSchema) + .input(insightsRoutingServicePaginatedInputSchema) .query(async ({ ctx, input }) => { - return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({ - teamId: input.teamId, - startDate: input.startDate, - endDate: input.endDate, - isAll: input.isAll, - organizationId: ctx.user.organizationId ?? null, - routingFormId: input.routingFormId, + const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({ userId: ctx.user.id, - memberUserIds: input.memberUserIds, - limit: input.limit ?? BATCH_SIZE, - offset: input.offset, - columnFilters: input.columnFilters, + teamId: input.selectedTeamId, + isAll: input.scope === "org", + organizationId: ctx.user.organizationId, + routingFormId: (input.columnFilters || []).find((filter) => filter.id === "formId")?.value?.data as + | string + | undefined, + }); + + const insightsRoutingService = createInsightsRoutingService(ctx, input); + const dataPromise = insightsRoutingService.getTableData({ sorting: input.sorting, + limit: input.limit, + offset: input.offset, + }); + + return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({ + headersPromise, + dataPromise, }); }), getRoutingFormFieldOptions: userBelongsToTeamProcedure diff --git a/packages/lib/server/service/InsightsRoutingBaseService.ts b/packages/lib/server/service/InsightsRoutingBaseService.ts index e5c8fe3b04..1a70dcabb8 100644 --- a/packages/lib/server/service/InsightsRoutingBaseService.ts +++ b/packages/lib/server/service/InsightsRoutingBaseService.ts @@ -12,6 +12,7 @@ import { } from "@calcom/features/data-table/lib/utils"; import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; +import type { BookingStatus } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRepository } from "../repository/membership"; @@ -51,8 +52,81 @@ export type InsightsRoutingServiceFilterOptions = { columnFilters?: TypedColumnFilter[]; }; +export type InsightsRoutingTableItem = { + id: number; + uuid: string | null; + formId: string; + formName: string; + formTeamId: number | null; + formUserId: number; + bookingUid: string | null; + bookingId: number | null; + bookingStatus: BookingStatus | null; + bookingStatusOrder: number | null; + bookingCreatedAt: Date | null; + bookingUserId: number | null; + bookingUserName: string | null; + bookingUserEmail: string | null; + bookingUserAvatarUrl: string | null; + bookingAssignmentReason: string | null; + bookingStartTime: Date | null; + bookingEndTime: Date | null; + eventTypeId: number | null; + eventTypeParentId: number | null; + eventTypeSchedulingType: string | null; + createdAt: Date; + utm_source: string | null; + utm_medium: string | null; + utm_campaign: string | null; + utm_term: string | null; + utm_content: string | null; + bookingAttendees: Array<{ + name: string; + timeZone: string; + email: string; + phoneNumber: string | null; + }>; + fields: Array<{ + fieldId: string | null; + valueString: string | null; + valueNumber: number | null; + valueStringArray: string[] | null; + }>; +}; + const NOTHING_CONDITION = Prisma.sql`1=0`; +// Define allowed column names for sorting to prevent SQL injection +const ALLOWED_SORT_COLUMNS = new Set([ + "id", + "uuid", + "formId", + "formName", + "formTeamId", + "formUserId", + "bookingUid", + "bookingId", + "bookingStatus", + "bookingStatusOrder", + "bookingCreatedAt", + "bookingUserId", + "bookingUserName", + "bookingUserEmail", + "bookingUserAvatarUrl", + "bookingAssignmentReason", + "bookingStartTime", + "bookingEndTime", + "eventTypeId", + "eventTypeParentId", + "eventTypeSchedulingType", + "createdAt", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", +]); + export class InsightsRoutingBaseService { private prisma: typeof readonlyPrisma; private options: InsightsRoutingServiceOptions | null; @@ -118,7 +192,7 @@ export class InsightsRoutingBaseService { END as "dateRange", "bookingUid", "bookingStatus" - FROM "RoutingFormResponseDenormalized" + FROM "RoutingFormResponseDenormalized" rfrd WHERE ${baseConditions} ) SELECT @@ -164,6 +238,166 @@ export class InsightsRoutingBaseService { }); } + /** + * Returns paginated table data for routing form responses. + * @param sorting Array of sorting objects with id and desc properties + * @param limit Number of records to return + * @param offset Number of records to skip + */ + async getTableData({ + sorting, + limit, + offset, + }: { + sorting?: Array<{ id: string; desc: boolean }>; + limit: number; + offset: number; + }) { + const baseConditions = await this.getBaseConditions(); + + // Build ORDER BY clause + const orderByClause = this.buildOrderByClause(sorting); + + // Get total count + const totalCountResult = await this.prisma.$queryRaw>` + SELECT COUNT(*) as count + FROM "RoutingFormResponseDenormalized" rfrd + WHERE ${baseConditions} + `; + + const totalCount = Number(totalCountResult[0]?.count || 0); + + // Get paginated data with JSON aggregation for attendees and fields + const data = await this.prisma.$queryRaw>` + SELECT + rfrd."id", + rfrd."uuid", + rfrd."formId", + rfrd."formName", + rfrd."formTeamId", + rfrd."formUserId", + rfrd."bookingUid", + rfrd."bookingId", + UPPER(rfrd."bookingStatus"::text) as "bookingStatus", + rfrd."bookingStatusOrder", + rfrd."bookingCreatedAt", + rfrd."bookingUserId", + rfrd."bookingUserName", + rfrd."bookingUserEmail", + rfrd."bookingUserAvatarUrl", + rfrd."bookingAssignmentReason", + rfrd."bookingStartTime", + rfrd."bookingEndTime", + rfrd."eventTypeId", + rfrd."eventTypeParentId", + rfrd."eventTypeSchedulingType", + rfrd."createdAt", + rfrd."utm_source", + rfrd."utm_medium", + rfrd."utm_campaign", + rfrd."utm_term", + rfrd."utm_content", + ( + SELECT COALESCE( + json_agg( + json_build_object( + 'name', a."name", + 'timeZone', a."timeZone", + 'email', a."email", + 'phoneNumber', a."phoneNumber" + ) + ) FILTER (WHERE a."id" IS NOT NULL), + '[]'::json + ) + FROM "Booking" b2 + LEFT JOIN "Attendee" a ON a."bookingId" = b2."id" + WHERE b2."uid" = rfrd."bookingUid" + ) as "bookingAttendees", + ( + SELECT COALESCE( + json_agg( + json_build_object( + 'fieldId', f."fieldId", + 'valueString', f."valueString", + 'valueNumber', f."valueNumber", + 'valueStringArray', f."valueStringArray" + ) + ) FILTER (WHERE f."fieldId" IS NOT NULL), + '[]'::json + ) + FROM "RoutingFormResponseField" f + WHERE f."responseId" = rfrd."id" + ) as "fields" + FROM "RoutingFormResponseDenormalized" rfrd + WHERE ${baseConditions} + ${orderByClause} + LIMIT ${limit} + OFFSET ${offset} + `; + + return { + total: totalCount, + data, + }; + } + + async getRoutingFormStats() { + const baseConditions = await this.getBaseConditions(); + + // Check if bookingUid filter is applied - if so, return null as metrics don't provide value + const bookingUid = this.filters.columnFilters?.find((filter) => filter.id === "bookingUid"); + if (bookingUid && isTextFilterValue(bookingUid.value) && bookingUid.value.data) { + // If bookingUid filter is applied, total count should be either 0 or 1. + // So this metrics doesn't provide any value. + return null; + } + + // Get total count + const totalResult = await this.prisma.$queryRaw>` + SELECT COUNT(*) as count + FROM "RoutingFormResponseDenormalized" rfrd + WHERE ${baseConditions} + `; + + // Get total without booking count + const totalWithoutBookingResult = await this.prisma.$queryRaw>` + SELECT COUNT(*) as count + FROM "RoutingFormResponseDenormalized" rfrd + WHERE (${baseConditions}) AND ("bookingUid" IS NULL) + `; + + const total = Number(totalResult[0]?.count || 0); + const totalWithoutBooking = Number(totalWithoutBookingResult[0]?.count || 0); + + return { + total, + totalWithoutBooking, + totalWithBooking: total - totalWithoutBooking, + }; + } + + private buildOrderByClause(sorting?: Array<{ id: string; desc: boolean }>): Prisma.Sql { + if (!sorting || sorting.length === 0) { + return Prisma.sql`ORDER BY "createdAt" DESC`; + } + + const orderByParts = sorting + .filter((sort) => ALLOWED_SORT_COLUMNS.has(sort.id)) + .map((sort) => { + const direction = sort.desc ? Prisma.sql`DESC` : Prisma.sql`ASC`; + return Prisma.sql`"${Prisma.raw(sort.id)}" ${direction}`; + }); + + if (orderByParts.length === 0) { + return Prisma.sql`ORDER BY "createdAt" DESC`; + } + + return Prisma.sql`ORDER BY ${orderByParts.reduce((acc, part, index) => { + if (index === 0) return part; + return Prisma.sql`${acc}, ${part}`; + })}`; + } + async getBaseConditions(): Promise { const authConditions = await this.getAuthorizationConditions(); const filterConditions = await this.getFilterConditions(); @@ -238,12 +472,30 @@ export class InsightsRoutingBaseService { } } - // Extract booking attendees filter - const bookingAttendees = filtersMap["bookingAttendees"]; - if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) { - const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value); - if (attendeesCondition) { - conditions.push(attendeesCondition); + // Extract attendee name filter + const attendeeName = filtersMap["attendeeName"]; + if (attendeeName && isTextFilterValue(attendeeName.value)) { + const nameCondition = this.buildAttendeeSqlCondition(attendeeName.value, "name"); + if (nameCondition) { + conditions.push(nameCondition); + } + } + + // Extract attendee email filter + const attendeeEmail = filtersMap["attendeeEmail"]; + if (attendeeEmail && isTextFilterValue(attendeeEmail.value)) { + const emailCondition = this.buildAttendeeSqlCondition(attendeeEmail.value, "email"); + if (emailCondition) { + conditions.push(emailCondition); + } + } + + // Extract attendee phone filter + const attendeePhone = filtersMap["attendeePhone"]; + if (attendeePhone && isTextFilterValue(attendeePhone.value)) { + const phoneCondition = this.buildAttendeeSqlCondition(attendeePhone.value, "phone"); + if (phoneCondition) { + conditions.push(phoneCondition); } } @@ -262,18 +514,9 @@ export class InsightsRoutingBaseService { } } - // Extract form field filters (exclude the system filters we already processed) - const alreadyHandledFilters = [ - "bookingStatusOrder", - "bookingAssignmentReason", - "bookingUid", - "bookingAttendees", - "bookingUserId", - "formId", - ]; - + const fieldIdSchema = z.string().uuid(); const fieldFilters = (this.filters.columnFilters || []).filter( - (filter) => !alreadyHandledFilters.includes(filter.id) + (filter) => fieldIdSchema.safeParse(filter.id).success ); if (fieldFilters.length > 0) { @@ -376,14 +619,14 @@ export class InsightsRoutingBaseService { // For multi-select fields, check if the field contains any of the selected values return Prisma.sql`EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" + WHERE rrf."responseId" = rfrd."id" AND rrf."fieldId" = ${fieldId} AND rrf."valueStringArray" @> ${filterValue.data.map(String)} )`; } else if (isSingleSelectFilterValue(filterValue)) { return Prisma.sql`EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" + WHERE rrf."responseId" = rfrd."id" AND rrf."fieldId" = ${fieldId} AND rrf."valueString" = ${filterValue.data} )`; @@ -394,7 +637,7 @@ export class InsightsRoutingBaseService { } return Prisma.sql`EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" + WHERE rrf."responseId" = rfrd."id" AND rrf."fieldId" = ${fieldId} AND rrf."valueString" ${condition} )`; @@ -405,7 +648,7 @@ export class InsightsRoutingBaseService { } return Prisma.sql`EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" + WHERE rrf."responseId" = rfrd."id" AND rrf."fieldId" = ${fieldId} AND rrf."valueNumber" ${condition} )`; @@ -414,27 +657,40 @@ export class InsightsRoutingBaseService { } } - private buildAttendeeSqlCondition(filterValue: TextFilterValue): Prisma.Sql | null { + private buildAttendeeSqlCondition( + filterValue: TextFilterValue, + attendeeColumn: "name" | "email" | "phone" + ): Prisma.Sql | null { if (!isTextFilterValue(filterValue)) { return null; } const textCondition = makeSqlCondition(filterValue); - if (!textCondition) { return null; } - const nameCondition = Prisma.sql`a.name ${textCondition}`; - const emailCondition = Prisma.sql`a.email ${textCondition}`; - - const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`; + // Use switch-case to avoid Prisma.raw for column names + let columnCondition: Prisma.Sql; + switch (attendeeColumn) { + case "name": + columnCondition = Prisma.sql`a.name ${textCondition}`; + break; + case "email": + columnCondition = Prisma.sql`a.email ${textCondition}`; + break; + case "phone": + columnCondition = Prisma.sql`a."phoneNumber" ${textCondition}`; + break; + default: + return null; + } return Prisma.sql`EXISTS ( SELECT 1 FROM "Booking" b INNER JOIN "Attendee" a ON a."bookingId" = b."id" - WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid" - AND (${combinedCondition}) + WHERE b."uid" = rfrd."bookingUid" + AND ${columnCondition} )`; } } diff --git a/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts b/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts index cd96f15f66..343f8df36d 100644 --- a/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts +++ b/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts @@ -1,7 +1,8 @@ import type { Team, User, Membership } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { randomUUID } from "crypto"; -import { describe, expect, it } from "vitest"; +import { v4 as uuid } from "uuid"; +import { describe, expect, it, vi, beforeAll, afterAll } from "vitest"; import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import prisma from "@calcom/prisma"; @@ -830,6 +831,14 @@ describe("InsightsRoutingService Integration Tests", () => { }); describe("columnFilters", () => { + beforeAll(() => { + vi.useFakeTimers().setSystemTime(new Date("2025-08-20T00:00:00.000Z")); + }); + + afterAll(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); it("should handle empty columnFilters", async () => { const testData = await createTestData({ teamRole: MembershipRole.OWNER, @@ -1044,7 +1053,7 @@ describe("InsightsRoutingService Integration Tests", () => { await testData.cleanup(); }); - it("should filter by booking attendees (text)", async () => { + it("should filter by attendee name (text)", async () => { const testData = await createTestData({ teamRole: MembershipRole.OWNER, orgRole: MembershipRole.OWNER, @@ -1063,10 +1072,10 @@ describe("InsightsRoutingService Integration Tests", () => { ...defaultFilters, columnFilters: [ { - id: "bookingAttendees", + id: "attendeeName", value: { type: ColumnFilterType.TEXT, - data: { operator: "contains", operand: "john@example.com" }, + data: { operator: "contains", operand: "john" }, }, }, ], @@ -1074,21 +1083,31 @@ describe("InsightsRoutingService Integration Tests", () => { }); const filterConditions = await service.getFilterConditions(); - expect(filterConditions).toEqual( - Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${ - defaultFilters.endDate - }::timestamp) AND (EXISTS ( - SELECT 1 FROM "Booking" b - INNER JOIN "Attendee" a ON a."bookingId" = b."id" - WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid" - AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`}) - ))` - ); + expect(filterConditions).toMatchInlineSnapshot(` + e { + "strings": [ + "("createdAt" >= ", + "::timestamp AND "createdAt" <= ", + "::timestamp) AND (EXISTS ( + SELECT 1 FROM "Booking" b + INNER JOIN "Attendee" a ON a."bookingId" = b."id" + WHERE b."uid" = rfrd."bookingUid" + AND a.name ILIKE ", + " + ))", + ], + "values": [ + "2025-08-18T00:00:00.000Z", + "2025-08-20T00:00:00.000Z", + "%john%", + ], + } + `); await testData.cleanup(); }); - it("should filter by custom form fields (text)", async () => { + it("should filter by attendee email (text)", async () => { const testData = await createTestData({ teamRole: MembershipRole.OWNER, orgRole: MembershipRole.OWNER, @@ -1107,7 +1126,117 @@ describe("InsightsRoutingService Integration Tests", () => { ...defaultFilters, columnFilters: [ { - id: "custom-field-id", + id: "attendeeEmail", + value: { + type: ColumnFilterType.TEXT, + data: { operator: "endsWith", operand: "@gmail.com" }, + }, + }, + ], + }, + }); + + const filterConditions = await service.getFilterConditions(); + expect(filterConditions).toMatchInlineSnapshot(` + e { + "strings": [ + "("createdAt" >= ", + "::timestamp AND "createdAt" <= ", + "::timestamp) AND (EXISTS ( + SELECT 1 FROM "Booking" b + INNER JOIN "Attendee" a ON a."bookingId" = b."id" + WHERE b."uid" = rfrd."bookingUid" + AND a.email ILIKE ", + " + ))", + ], + "values": [ + "2025-08-18T00:00:00.000Z", + "2025-08-20T00:00:00.000Z", + "%@gmail.com", + ], + } + `); + + await testData.cleanup(); + }); + + it("should filter by attendee phone number (text)", 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: "attendeePhone", + value: { + type: ColumnFilterType.TEXT, + data: { operator: "contains", operand: "000" }, + }, + }, + ], + }, + }); + + const filterConditions = await service.getFilterConditions(); + expect(filterConditions).toMatchInlineSnapshot(` + e { + "strings": [ + "("createdAt" >= ", + "::timestamp AND "createdAt" <= ", + "::timestamp) AND (EXISTS ( + SELECT 1 FROM "Booking" b + INNER JOIN "Attendee" a ON a."bookingId" = b."id" + WHERE b."uid" = rfrd."bookingUid" + AND a."phoneNumber" ILIKE ", + " + ))", + ], + "values": [ + "2025-08-18T00:00:00.000Z", + "2025-08-20T00:00:00.000Z", + "%000%", + ], + } + `); + + await testData.cleanup(); + }); + + it("should filter by custom form fields (text)", async () => { + const customFieldId = uuid(); + + 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: customFieldId, value: { type: ColumnFilterType.TEXT, data: { operator: "equals", operand: "test value" }, @@ -1123,8 +1252,8 @@ describe("InsightsRoutingService Integration Tests", () => { defaultFilters.endDate }::timestamp) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" - AND rrf."fieldId" = ${"custom-field-id"} + WHERE rrf."responseId" = rfrd."id" + AND rrf."fieldId" = ${customFieldId} AND rrf."valueString" = ${"test value"} ))` ); @@ -1138,6 +1267,7 @@ describe("InsightsRoutingService Integration Tests", () => { orgRole: MembershipRole.OWNER, }); + const customFieldId = uuid(); const defaultFilters = createDefaultFilters(); const service = new InsightsRoutingService({ prisma, @@ -1151,7 +1281,7 @@ describe("InsightsRoutingService Integration Tests", () => { ...defaultFilters, columnFilters: [ { - id: "custom-multi-field-id", + id: customFieldId, value: { type: ColumnFilterType.MULTI_SELECT, data: ["option1", "option2"], @@ -1167,8 +1297,8 @@ describe("InsightsRoutingService Integration Tests", () => { defaultFilters.endDate }::timestamp) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" - AND rrf."fieldId" = ${"custom-multi-field-id"} + WHERE rrf."responseId" = rfrd."id" + AND rrf."fieldId" = ${customFieldId} AND rrf."valueStringArray" @> ${["option1", "option2"]} ))` ); @@ -1177,6 +1307,7 @@ describe("InsightsRoutingService Integration Tests", () => { }); it("should combine multiple filters with AND", async () => { + const customFieldId = uuid(); const testData = await createTestData({ teamRole: MembershipRole.OWNER, orgRole: MembershipRole.OWNER, @@ -1209,7 +1340,7 @@ describe("InsightsRoutingService Integration Tests", () => { }, }, { - id: "custom-field-id", + id: customFieldId, value: { type: ColumnFilterType.TEXT, data: { operator: "equals", operand: "test" }, @@ -1227,8 +1358,8 @@ describe("InsightsRoutingService Integration Tests", () => { "pending", ]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" - AND rrf."fieldId" = ${"custom-field-id"} + WHERE rrf."responseId" = rfrd."id" + AND rrf."fieldId" = ${customFieldId} AND rrf."valueString" = ${"test"} ))` ); @@ -1330,6 +1461,8 @@ describe("InsightsRoutingService Integration Tests", () => { orgRole: MembershipRole.OWNER, }); + const customFieldId = uuid(); + const defaultFilters = createDefaultFilters(); const service = new InsightsRoutingService({ prisma, @@ -1350,7 +1483,7 @@ describe("InsightsRoutingService Integration Tests", () => { }, }, { - id: "custom-field-id", // Custom field filter + id: customFieldId, // Custom field filter value: { type: ColumnFilterType.TEXT, data: { operator: "equals", operand: "test" }, @@ -1366,8 +1499,8 @@ describe("InsightsRoutingService Integration Tests", () => { defaultFilters.endDate }::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS ( SELECT 1 FROM "RoutingFormResponseField" rrf - WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id" - AND rrf."fieldId" = ${"custom-field-id"} + WHERE rrf."responseId" = rfrd."id" + AND rrf."fieldId" = ${customFieldId} AND rrf."valueString" = ${"test"} ))` );