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:
co-authored by
coderabbitai[bot]
parent
d72e15649f
commit
cf367c9340
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
))`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user