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"}
))`
);