feat: add attendee filters to /insights/routing (+ use RoutingFormResponseDenormalized) (#21297)

* fix: denormalize RoutingFormResponse with InsightsRoutingService

* fix tests

* fix type error

* update service usage

* fix attendee filters

* display attendee phone number

* use useInsightsRoutingParameters()

* remove unnecessary type check

* clean up

* fix type error

* fix wrong import

* fix integration tests

* Update packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-08-21 14:09:22 +02:00
committed by GitHub
co-authored by coderabbitai[bot]
parent d72e15649f
commit cf367c9340
13 changed files with 630 additions and 482 deletions
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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 <div className="min-w-[200px]" />;
return (
<div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
{attendees.map((attendee) => {
const tooltipContent = (
<div className="space-y-1">
<div>
{t("email")}: {attendee.email}
</div>
{attendee.phoneNumber && (
<div>
{t("phone")}: {attendee.phoneNumber}
</div>
)}
</div>
);
return (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Tooltip content={tooltipContent || undefined}>
<Badge variant="gray" className="whitespace-nowrap">
{attendee.name}
</Badge>
</Tooltip>
</CellWithOverflowX>
);
})}
</div>
);
}
@@ -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<FilterableColumn, { type: ColumnFilterType.DATE_R
};
export function RoutingFormResponsesTable() {
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const { isAll, teamId, userId, routingFormId } = useInsightsParameters();
const { data: headers, isSuccess: isHeadersSuccess } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
@@ -53,22 +53,15 @@ export function RoutingFormResponsesTable() {
isAll,
});
const insightsRoutingParams = useInsightsRoutingParameters();
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
teamId,
startDate,
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
...insightsRoutingParams,
sorting,
limit,
offset,
});
const processedData = useMemo(() => {
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,
@@ -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;
@@ -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,
@@ -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 <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
},
}),
// 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<string, string>) ?? {};
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 && (
<ResponseValueCell
optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
values={Array.isArray(result.data) ? result.data : [result.data]}
rowId={info.row.original.id}
/>
)
@@ -167,7 +210,7 @@ export const useInsightsColumns = ({
return (
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
<span title={String(result.data)}>{result.data}</span>
</div>
)
);
@@ -51,7 +51,7 @@ export function useInsightsParameters() {
}, [createdAtRange?.preset]);
const columnFilters = useColumnFilters({
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
exclude: ["createdAt"],
});
return {
+4 -16
View File
@@ -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,
@@ -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<RoutingFormStatsInput>;
export const routingFormResponsesInputSchema = z.object({
...routingFormStatsInputSchema.shape,
offset: z.number().optional(),
limit: z.number().max(100).optional(),
}) satisfies z.ZodType<RoutingFormResponsesInput>;
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<InsightsRoutingServiceInput>;
export const insightsRoutingServicePaginatedInputSchema = z.object({
...insightsRoutingServiceInputSchema.shape,
offset: z.number(),
limit: z.number().max(100),
sorting: z.array(ZSorting).optional(),
}) satisfies z.ZodType<InsightsRoutingServicePaginatedInput>;
export const routingRepositoryBaseInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
@@ -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<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
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 = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
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<ColumnFilterType.TEXT> | undefined;
const assignmentReasonValue = bookingAssignmentReason
? getLowercasedFilterValue(bookingAssignmentReason)
: undefined;
const bookingUid = columnFilters.find((filter) => filter.id === "bookingUid") as
| TypedColumnFilter<ColumnFilterType.TEXT>
| 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<string, ResponseValue>;
responseLowercase: Record<string, ResponseValue>;
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<typeof RoutingEventsInsights.getRoutingFormHeaders>;
dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
}) {
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<string, string | undefined>);
@@ -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<string, string>) || {}),
};
});
@@ -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
@@ -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<ColumnFilterType>[];
};
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<Array<{ count: bigint }>>`
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<Array<InsightsRoutingTableItem>>`
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<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM "RoutingFormResponseDenormalized" rfrd
WHERE ${baseConditions}
`;
// Get total without booking count
const totalWithoutBookingResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
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<Prisma.Sql> {
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}
)`;
}
}
@@ -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"}
))`
);