diff --git a/packages/features/insights/components/routing/RoutedToPerPeriod.tsx b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx
index 4e202aa3865ce7..044e1c7197ee98 100644
--- a/packages/features/insights/components/routing/RoutedToPerPeriod.tsx
+++ b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx
@@ -6,7 +6,7 @@ import { type ReactNode, useMemo, useRef, useState } from "react";
import { DataTableSkeleton } from "@calcom/features/data-table";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
-import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
+import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -26,46 +26,26 @@ import {
} from "@calcom/ui/components/table";
import { Tooltip } from "@calcom/ui/components/tooltip";
-import { useInsightsParameters } from "../../hooks/useInsightsParameters";
+import { useInsightsRoutingParameters } from "../../hooks/useInsightsRoutingParameters";
import { ChartCard } from "../ChartCard";
interface DownloadButtonProps {
- teamId?: number;
- userId?: number;
- isAll?: boolean;
- routingFormId?: string;
- startDate: string;
- endDate: string;
- selectedPeriod: string;
+ selectedPeriod: "perDay" | "perWeek" | "perMonth";
searchQuery?: string;
}
-function DownloadButton({
- userId,
- teamId,
- isAll,
- routingFormId,
- startDate,
- endDate,
- selectedPeriod,
- searchQuery,
-}: DownloadButtonProps) {
+function DownloadButton({ selectedPeriod, searchQuery }: DownloadButtonProps) {
const [isDownloading, setIsDownloading] = useState(false);
+ const routingParams = useInsightsRoutingParameters();
const utils = trpc.useContext();
- const { t } = useLocale();
const handleDownload = async (e: React.MouseEvent) => {
e.preventDefault(); // Prevent default form submission
try {
const result = await utils.viewer.insights.routedToPerPeriodCsv.fetch({
- userId,
- teamId,
- startDate,
- endDate,
- period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
- isAll,
- routingFormId,
+ ...routingParams,
+ period: selectedPeriod,
searchQuery: searchQuery || undefined,
});
@@ -94,32 +74,14 @@ function DownloadButton({
}
interface FormCardProps {
- selectedPeriod: string;
- onPeriodChange: (value: string) => void;
+ selectedPeriod: "perDay" | "perWeek" | "perMonth";
+ onPeriodChange: (value: "perDay" | "perWeek" | "perMonth") => void;
searchQuery: string;
onSearchChange: (value: string) => void;
children: ReactNode;
- teamId?: number;
- userId?: number;
- isAll?: boolean;
- routingFormId?: string;
- startDate: string;
- endDate: string;
}
-function FormCard({
- selectedPeriod,
- onPeriodChange,
- searchQuery,
- onSearchChange,
- children,
- teamId,
- userId,
- isAll,
- routingFormId,
- startDate,
- endDate,
-}: FormCardProps) {
+function FormCard({ selectedPeriod, onPeriodChange, searchQuery, onSearchChange, children }: FormCardProps) {
const { t } = useLocale();
return (
@@ -135,7 +97,7 @@ function FormCard({
]}
className="w-fit"
value={selectedPeriod}
- onValueChange={(value) => value && onPeriodChange(value)}
+ onValueChange={(value) => value && onPeriodChange(value as "perDay" | "perWeek" | "perMonth")}
/>
@@ -147,16 +109,7 @@ function FormCard({
className="w-full"
/>
-
+
{children}
@@ -215,87 +168,56 @@ const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TF
export function RoutedToPerPeriod() {
const { t } = useLocale();
- const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
- const [selectedPeriod, setSelectedPeriod] = useQueryState("selectedPeriod", {
- defaultValue: "perWeek",
- });
+ const routingParams = useInsightsRoutingParameters();
+ const [selectedPeriod, setSelectedPeriod] = useState<"perDay" | "perWeek" | "perMonth">("perWeek");
const [searchQuery, setSearchQuery] = useQueryState("search", {
defaultValue: "",
});
- const { ref: loadMoreRef } = useInViewObserver(() => {
- if (hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- });
-
const tableContainerRef = useRef(null);
-
- const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
- trpc.viewer.insights.routedToPerPeriod.useInfiniteQuery(
- {
- userId,
- teamId,
- startDate,
- endDate,
- period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
- isAll,
- routingFormId,
- searchQuery: searchQuery || undefined,
- limit: 10,
+ const debouncedSearchQuery = useDebounce(searchQuery, 500);
+
+ const { data, isLoading } = trpc.viewer.insights.routedToPerPeriod.useQuery(
+ {
+ ...routingParams,
+ period: selectedPeriod,
+ searchQuery: debouncedSearchQuery || undefined,
+ },
+ {
+ staleTime: 30000,
+ refetchOnWindowFocus: false,
+ trpc: {
+ context: { skipBatch: true },
},
- {
- getNextPageParam: (lastPage) => {
- if (!lastPage.users.nextCursor && !lastPage.periodStats.nextCursor) {
- return undefined;
- }
-
- return {
- userCursor: lastPage.users.nextCursor,
- periodCursor: lastPage.periodStats.nextCursor,
- };
- },
- }
- );
+ }
+ );
const flattenedUsers = useMemo(() => {
- const userMap = new Map();
- data?.pages.forEach((page) => {
- page.users.data.forEach((user) => {
- if (!userMap.has(user.id)) {
- userMap.set(user.id, user);
- }
- });
- });
- return Array.from(userMap.values());
- }, [data?.pages]);
+ return data?.users.data || [];
+ }, [data?.users.data]);
const uniquePeriods = useMemo(() => {
- if (!data?.pages) return [];
+ if (!data?.periodStats.data) return [];
- // Get all unique periods from all pages
+ // Get all unique periods
const periods = new Set();
- data.pages.forEach((page) => {
- page.periodStats.data.forEach((stat) => {
- periods.add(stat.period_start.toISOString());
- });
+ data.periodStats.data.forEach((stat) => {
+ periods.add(stat.period_start.toISOString());
});
return Array.from(periods)
.map((dateStr) => new Date(dateStr))
.sort((a, b) => a.getTime() - b.getTime());
- }, [data?.pages]);
+ }, [data?.periodStats.data]);
const processedData = useMemo(() => {
- if (!data?.pages) return [];
+ if (!data?.periodStats.data) return [];
// Create a map for quick lookup of stats
const statsMap = new Map();
- data.pages.forEach((page) => {
- page.periodStats.data.forEach((stat) => {
- const key = `${stat.userId}-${stat.period_start.toISOString()}`;
- statsMap.set(key, stat.total);
- });
+ data.periodStats.data.forEach((stat) => {
+ const key = `${stat.userId}-${stat.period_start.toISOString()}`;
+ statsMap.set(key, stat.total);
});
return flattenedUsers.map((user) => {
@@ -314,7 +236,7 @@ export function RoutedToPerPeriod() {
totalBookings: user.totalBookings,
};
});
- }, [data?.pages, flattenedUsers, uniquePeriods]);
+ }, [data?.periodStats.data, flattenedUsers, uniquePeriods]);
if (isLoading) {
return (
@@ -327,13 +249,7 @@ export function RoutedToPerPeriod() {
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
- onSearchChange={setSearchQuery}
- userId={userId}
- teamId={teamId}
- isAll={isAll}
- routingFormId={routingFormId}
- startDate={startDate}
- endDate={endDate}>
+ onSearchChange={setSearchQuery}>
@@ -368,13 +284,7 @@ export function RoutedToPerPeriod() {
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
- onSearchChange={setSearchQuery}
- userId={userId}
- teamId={teamId}
- isAll={isAll}
- routingFormId={routingFormId}
- startDate={startDate}
- endDate={endDate}>
+ onSearchChange={setSearchQuery}>
{processedData.map((row, index) => {
return (
-
+
diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts
index cb7e5fbb812326..ba2384ec46b03f 100644
--- a/packages/features/insights/server/raw-data.schema.ts
+++ b/packages/features/insights/server/raw-data.schema.ts
@@ -89,6 +89,17 @@ export const routingRepositoryBaseInputSchema = z.object({
columnFilters: z.array(ZColumnFilter).optional(),
});
+export const routedToPerPeriodInputSchema = routingRepositoryBaseInputSchema.extend({
+ period: z.enum(["perDay", "perWeek", "perMonth"]),
+ limit: z.number().int().min(1).max(100).default(10),
+ searchQuery: z.string().trim().min(1).optional(),
+});
+
+export const routedToPerPeriodCsvInputSchema = routingRepositoryBaseInputSchema.extend({
+ period: z.enum(["perDay", "perWeek", "perMonth"]),
+ searchQuery: z.string().trim().min(1).optional(),
+});
+
export const bookingRepositoryBaseInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
selectedTeamId: z.number().optional(),
diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts
index 4b00c39ab4229e..6e5e7ca42ce125 100644
--- a/packages/features/insights/server/routing-events.ts
+++ b/packages/features/insights/server/routing-events.ts
@@ -9,7 +9,6 @@ import {
isValidRoutingFormFieldType,
} 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 { WEBAPP_URL } from "@calcom/lib/constants";
import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
import { readonlyPrisma as prisma } from "@calcom/prisma";
@@ -413,487 +412,6 @@ class RoutingEventsInsights {
return headers;
}
- static async routedToPerPeriod({
- userId,
- teamId,
- isAll,
- organizationId,
- routingFormId,
- startDate: _startDate,
- endDate: _endDate,
- period,
- cursor,
- userCursor,
- limit = 10,
- searchQuery,
- }: RoutingFormInsightsTeamFilter & {
- period: "perDay" | "perWeek" | "perMonth";
- startDate: string;
- endDate: string;
- cursor?: string;
- userCursor?: number;
- limit?: number;
- searchQuery?: string;
- }) {
- const dayJsPeriodMap = {
- perDay: "day",
- perWeek: "week",
- perMonth: "month",
- } as const;
-
- const dayjsPeriod = dayJsPeriodMap[period];
- const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
- const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
-
- // Build the team conditions for the WHERE clause
- const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
- userId,
- teamId,
- isAll,
- organizationId,
- routingFormId,
- });
-
- const teamConditions = [];
-
- // @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
- if (formsWhereCondition.teamId?.in) {
- // @ts-expect-error same as above
- teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
- }
- // @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
- if (!formsWhereCondition.teamId?.in && userId) {
- teamConditions.push(`f."userId" = ${userId}`);
- }
- if (routingFormId) {
- teamConditions.push(`f.id = '${routingFormId}'`);
- }
-
- const searchCondition = searchQuery
- ? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
- : Prisma.empty;
-
- const whereClause = teamConditions.length
- ? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
- : Prisma.empty;
-
- // Get users who have been routed to during the period
- const usersQuery = await prisma.$queryRaw<
- Array<{
- id: number;
- name: string | null;
- email: string;
- avatarUrl: string | null;
- }>
- >`
- WITH routed_responses AS (
- SELECT DISTINCT ON (b."userId")
- b."userId",
- u.id,
- u.name,
- u.email,
- u."avatarUrl"
- FROM "App_RoutingForms_FormResponse" r
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- JOIN "Booking" b ON r."routedToBookingUid" = b.uid
- JOIN "users" u ON b."userId" = u.id
- WHERE r."routedToBookingUid" IS NOT NULL
- AND r."createdAt" >= ${startDate}
- AND r."createdAt" <= ${endDate}
- ${searchCondition}
- ${whereClause}
- ${userCursor ? Prisma.sql`AND b."userId" > ${userCursor}` : Prisma.empty}
- ORDER BY b."userId", r."createdAt" DESC
- )
- SELECT *
- FROM routed_responses
- ORDER BY id ASC
- LIMIT ${limit}
- `;
-
- const users = usersQuery;
-
- const hasMoreUsers = users.length === limit;
-
- // Return early if no users found
- if (users.length === 0) {
- return {
- users: {
- data: [],
- nextCursor: undefined,
- },
- periodStats: {
- data: [],
- nextCursor: undefined,
- },
- };
- }
-
- // Get periods with pagination
- const periodStats = await prisma.$queryRaw<
- Array<{
- userId: number;
- period_start: Date;
- total: number;
- }>
- >`
- -- First, generate all months in the range
- WITH RECURSIVE date_range AS (
- SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
- UNION ALL
- SELECT date + (CASE
- WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
- WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
- WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
- END)
- FROM date_range
- WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
- ),
- -- Get all unique users we want to show
- all_users AS (
- SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
- ),
- -- Get periods with pagination
- paginated_periods AS (
- SELECT date as period_start
- FROM date_range
- ORDER BY date ASC
- ${cursor ? Prisma.sql`OFFSET ${parseInt(cursor, 10)}` : Prisma.empty}
- LIMIT ${limit}
- ),
- -- Generate combinations for paginated periods
- date_user_combinations AS (
- SELECT
- period_start,
- user_id as "userId"
- FROM paginated_periods
- CROSS JOIN all_users
- ),
- -- Count bookings per user per period
- booking_counts AS (
- SELECT
- b."userId",
- date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
- COUNT(DISTINCT b.id)::integer as total
- FROM "Booking" b
- JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- WHERE b."userId" IN (SELECT user_id FROM all_users)
- AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(period_start) FROM paginated_periods)
- AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(period_start) FROM paginated_periods)
- ${whereClause}
- GROUP BY 1, 2
- )
- -- Join everything together
- SELECT
- c."userId",
- c.period_start,
- COALESCE(b.total, 0)::integer as total
- FROM date_user_combinations c
- LEFT JOIN booking_counts b ON
- b."userId" = c."userId" AND
- b.period_start = c.period_start
- ORDER BY c.period_start ASC, c."userId" ASC
- `;
-
- // Get total number of periods to determine if there are more
- const totalPeriodsQuery = await prisma.$queryRaw<[{ count: number }]>`
- WITH RECURSIVE date_range AS (
- SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
- UNION ALL
- SELECT date + (CASE
- WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
- WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
- WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
- END)
- FROM date_range
- WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
- )
- SELECT COUNT(*)::integer as count FROM date_range
- `;
-
- // Get statistics for the entire period for comparison
- const statsQuery = await prisma.$queryRaw<
- Array<{
- userId: number;
- total_bookings: number;
- }>
- >`
- SELECT
- b."userId",
- COUNT(*)::integer as total_bookings
- FROM "App_RoutingForms_FormResponse" r
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- JOIN "Booking" b ON r."routedToBookingUid" = b.uid
- WHERE r."routedToBookingUid" IS NOT NULL
- AND r."createdAt" >= ${startDate}
- AND r."createdAt" <= ${endDate}
- ${whereClause}
- GROUP BY b."userId"
- ORDER BY total_bookings ASC
- `;
-
- // Calculate average and median
- const average =
- statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
- const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
-
- // Create a map of user performance indicators
- const userPerformance = statsQuery.reduce((acc, stat) => {
- acc[stat.userId] = {
- total: stat.total_bookings,
- performance:
- stat.total_bookings > average
- ? "above_average"
- : stat.total_bookings === median
- ? "median"
- : stat.total_bookings < average
- ? "below_average"
- : "at_average",
- };
- return acc;
- }, {} as Record);
-
- const totalPeriods = totalPeriodsQuery[0].count;
- const currentPeriodOffset = cursor ? parseInt(cursor, 10) : 0;
- const hasMorePeriods = currentPeriodOffset + limit < totalPeriods;
-
- return {
- users: {
- data: users.map((user) => ({
- ...user,
- performance: userPerformance[user.id]?.performance || "no_data",
- totalBookings: userPerformance[user.id]?.total || 0,
- })),
- nextCursor: hasMoreUsers ? users[users.length - 1].id : undefined,
- },
- periodStats: {
- data: periodStats,
- nextCursor: hasMorePeriods ? (currentPeriodOffset + limit).toString() : undefined,
- },
- };
- }
-
- static async routedToPerPeriodCsv({
- userId,
- teamId,
- isAll,
- organizationId,
- routingFormId,
- startDate: _startDate,
- endDate: _endDate,
- period,
- searchQuery,
- }: RoutingFormInsightsTeamFilter & {
- period: "perDay" | "perWeek" | "perMonth";
- startDate: string;
- endDate: string;
- searchQuery?: string;
- }) {
- const dayJsPeriodMap = {
- perDay: "day",
- perWeek: "week",
- perMonth: "month",
- } as const;
-
- const dayjsPeriod = dayJsPeriodMap[period];
- const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
- const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
-
- // Build the team conditions for the WHERE clause
- const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
- userId,
- teamId,
- isAll,
- organizationId,
- routingFormId,
- });
-
- const teamConditions = [];
-
- // @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
- if (formsWhereCondition.teamId?.in) {
- // @ts-expect-error same as above
- teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
- }
- // @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
- if (!formsWhereCondition.teamId?.in && userId) {
- teamConditions.push(`f."userId" = ${userId}`);
- }
- if (routingFormId) {
- teamConditions.push(`f.id = '${routingFormId}'`);
- }
-
- const searchCondition = searchQuery
- ? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
- : Prisma.empty;
-
- const whereClause = teamConditions.length
- ? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
- : Prisma.empty;
-
- // Get users who have been routed to during the period
- const usersQuery = await prisma.$queryRaw<
- Array<{
- id: number;
- name: string | null;
- email: string;
- avatarUrl: string | null;
- }>
- >`
- WITH routed_responses AS (
- SELECT DISTINCT ON (b."userId")
- b."userId",
- u.id,
- u.name,
- u.email,
- u."avatarUrl"
- FROM "App_RoutingForms_FormResponse" r
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- JOIN "Booking" b ON r."routedToBookingUid" = b.uid
- JOIN "users" u ON b."userId" = u.id
- WHERE r."routedToBookingUid" IS NOT NULL
- AND r."createdAt" >= ${startDate}
- AND r."createdAt" <= ${endDate}
- ${searchCondition}
- ${whereClause}
- ORDER BY b."userId", r."createdAt" DESC
- )
- SELECT *
- FROM routed_responses
- ORDER BY id ASC
- `;
-
- // Get all periods without pagination
- const periodStats = await prisma.$queryRaw<
- Array<{
- userId: number;
- period_start: Date;
- total: number;
- }>
- >`
- WITH RECURSIVE date_range AS (
- SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
- UNION ALL
- SELECT date + (CASE
- WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
- WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
- WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
- END)
- FROM date_range
- WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
- ),
- all_users AS (
- SELECT unnest(ARRAY[${Prisma.join(usersQuery.map((u) => u.id))}]) as user_id
- ),
- date_user_combinations AS (
- SELECT
- date as period_start,
- user_id as "userId"
- FROM date_range
- CROSS JOIN all_users
- ),
- booking_counts AS (
- SELECT
- b."userId",
- date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
- COUNT(DISTINCT b.id)::integer as total
- FROM "Booking" b
- JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- WHERE b."userId" IN (SELECT user_id FROM all_users)
- AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(date) FROM date_range)
- AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(date) FROM date_range)
- ${whereClause}
- GROUP BY 1, 2
- )
- SELECT
- c."userId",
- c.period_start,
- COALESCE(b.total, 0)::integer as total
- FROM date_user_combinations c
- LEFT JOIN booking_counts b ON
- b."userId" = c."userId" AND
- b.period_start = c.period_start
- ORDER BY c.period_start ASC, c."userId" ASC
- `;
-
- // Get statistics for the entire period for comparison
- const statsQuery = await prisma.$queryRaw<
- Array<{
- userId: number;
- total_bookings: number;
- }>
- >`
- SELECT
- b."userId",
- COUNT(*)::integer as total_bookings
- FROM "App_RoutingForms_FormResponse" r
- JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
- JOIN "Booking" b ON r."routedToBookingUid" = b.uid
- WHERE r."routedToBookingUid" IS NOT NULL
- AND r."createdAt" >= ${startDate}
- AND r."createdAt" <= ${endDate}
- ${whereClause}
- GROUP BY b."userId"
- ORDER BY total_bookings ASC
- `;
-
- // Calculate average and median
- const average =
- statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
- const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
-
- // Create a map of user performance indicators
- const userPerformance = statsQuery.reduce((acc, stat) => {
- acc[stat.userId] = {
- total: stat.total_bookings,
- performance:
- stat.total_bookings > average
- ? "above_average"
- : stat.total_bookings === median
- ? "median"
- : stat.total_bookings < average
- ? "below_average"
- : "at_average",
- };
- return acc;
- }, {} as Record);
-
- // Group period stats by user
- const userPeriodStats = periodStats.reduce((acc, stat) => {
- if (!acc[stat.userId]) {
- acc[stat.userId] = [];
- }
- acc[stat.userId].push({
- period_start: stat.period_start,
- total: stat.total,
- });
- return acc;
- }, {} as Record>);
-
- // Format data for CSV
- return usersQuery.map((user) => {
- const stats = userPeriodStats[user.id] || [];
- const periodData = stats.reduce(
- (acc, stat) => ({
- ...acc,
- [`Responses ${dayjs(stat.period_start).format("YYYY-MM-DD")}`]: stat.total.toString(),
- }),
- {} as Record
- );
-
- return {
- "User ID": user.id.toString(),
- Name: user.name || "",
- Email: user.email,
- "Total Bookings": (userPerformance[user.id]?.total || 0).toString(),
- Performance: userPerformance[user.id]?.performance || "no_data",
- ...periodData,
- };
- });
- }
-
static objectToCsv(data: Record[]) {
if (!data.length) return "";
diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts
index 7384ae7dc07d6b..e08ffb8fe119fb 100644
--- a/packages/features/insights/server/trpc-router.ts
+++ b/packages/features/insights/server/trpc-router.ts
@@ -3,10 +3,11 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
- rawDataInputSchema,
insightsRoutingServiceInputSchema,
insightsRoutingServicePaginatedInputSchema,
routingRepositoryBaseInputSchema,
+ routedToPerPeriodInputSchema,
+ routedToPerPeriodCsvInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { getInsightsBookingService } from "@calcom/lib/di/containers/InsightsBooking";
@@ -921,65 +922,36 @@ export const insightsRouter = router({
return headers || [];
}),
routedToPerPeriod: userBelongsToTeamProcedure
- .input(
- rawDataInputSchema.extend({
- period: z.enum(["perDay", "perWeek", "perMonth"]),
- cursor: z
- .object({
- userCursor: z.number().optional(),
- periodCursor: z.string().optional(),
- })
- .optional(),
- routingFormId: z.string().optional(),
- limit: z.number().optional(),
- searchQuery: z.string().optional(),
- })
- )
+ .input(routedToPerPeriodInputSchema)
.query(async ({ ctx, input }) => {
- const { teamId, userId, startDate, endDate, period, cursor, limit, isAll, routingFormId, searchQuery } =
- input;
+ const { period, limit, searchQuery, ...rest } = input;
- return await RoutingEventsInsights.routedToPerPeriod({
- userId: ctx.user.id,
- teamId: teamId ?? null,
- startDate,
- endDate,
- period,
- cursor: cursor?.periodCursor,
- userCursor: cursor?.userCursor,
- limit,
- isAll: isAll ?? false,
- organizationId: ctx.user.organizationId ?? null,
- routingFormId: routingFormId ?? null,
- searchQuery: searchQuery,
- });
+ try {
+ const insightsRoutingService = createInsightsRoutingService(ctx, rest);
+ return await insightsRoutingService.getRoutedToPerPeriodData({
+ period,
+ limit,
+ searchQuery,
+ });
+ } catch (e) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+ }
}),
routedToPerPeriodCsv: userBelongsToTeamProcedure
- .input(
- rawDataInputSchema.extend({
- period: z.enum(["perDay", "perWeek", "perMonth"]),
- searchQuery: z.string().optional(),
- routingFormId: z.string().optional(),
- })
- )
+ .input(routedToPerPeriodCsvInputSchema)
.query(async ({ ctx, input }) => {
- const { startDate, endDate } = input;
+ const { period, searchQuery, ...rest } = input;
try {
- const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
- userId: ctx.user.id,
- teamId: input.teamId ?? null,
- startDate,
- endDate,
- isAll: input.isAll ?? false,
- organizationId: ctx.user.organizationId ?? null,
- routingFormId: input.routingFormId ?? null,
- period: input.period,
- searchQuery: input.searchQuery,
+ const insightsRoutingService = createInsightsRoutingService(ctx, rest);
+
+ const csvData = await insightsRoutingService.getRoutedToPerPeriodCsvData({
+ period,
+ searchQuery,
});
- const csvString = RoutingEventsInsights.objectToCsv(csvData);
- const downloadAs = `routed-to-${input.period}-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(
- endDate
+ const csvString = objectToCsv(csvData);
+ const downloadAs = `routed-to-${period}-${dayjs(rest.startDate).format("YYYY-MM-DD")}-${dayjs(
+ rest.endDate
).format("YYYY-MM-DD")}.csv`;
return { data: csvString, filename: downloadAs };
@@ -1147,3 +1119,25 @@ async function getEventTypeList({
return eventTypeResult;
}
+
+function objectToCsv(data: Record[]) {
+ if (!data.length) return "";
+
+ const headers = Object.keys(data[0]);
+ const csvRows = [
+ headers.join(","),
+ ...data.map((row) =>
+ headers
+ .map((header) => {
+ const value = row[header]?.toString() || "";
+ // Escape quotes and wrap in quotes if contains comma or newline
+ return value.includes(",") || value.includes("\n") || value.includes('"')
+ ? `"${value.replace(/"/g, '""')}"` // escape double quotes
+ : value;
+ })
+ .join(",")
+ ),
+ ];
+
+ return csvRows.join("\n");
+}
diff --git a/packages/lib/server/service/InsightsRoutingBaseService.ts b/packages/lib/server/service/InsightsRoutingBaseService.ts
index 1a70dcabb8098f..9bd8cbc4d6d2e7 100644
--- a/packages/lib/server/service/InsightsRoutingBaseService.ts
+++ b/packages/lib/server/service/InsightsRoutingBaseService.ts
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
+import dayjs from "@calcom/dayjs";
import { makeSqlCondition } from "@calcom/features/data-table/lib/server";
import type { FilterValue, TextFilterValue, TypedColumnFilter } from "@calcom/features/data-table/lib/types";
import type { ColumnFilterType } from "@calcom/features/data-table/lib/types";
@@ -47,8 +48,8 @@ export type InsightsRoutingServicePublicOptions = {
export type InsightsRoutingServiceOptions = z.infer;
export type InsightsRoutingServiceFilterOptions = {
- startDate?: string;
- endDate?: string;
+ startDate: string;
+ endDate: string;
columnFilters?: TypedColumnFilter[];
};
@@ -127,12 +128,17 @@ const ALLOWED_SORT_COLUMNS = new Set([
"utm_content",
]);
+type GetConditionsOptions = {
+ exclude?: {
+ createdAt?: boolean;
+ columnFilterIds?: string[];
+ };
+};
+
export class InsightsRoutingBaseService {
private prisma: typeof readonlyPrisma;
private options: InsightsRoutingServiceOptions | null;
private filters: InsightsRoutingServiceFilterOptions;
- private cachedAuthConditions?: Prisma.Sql;
- private cachedFilterConditions?: Prisma.Sql | null;
constructor({
prisma,
@@ -398,9 +404,254 @@ export class InsightsRoutingBaseService {
})}`;
}
- async getBaseConditions(): Promise {
+ /**
+ * Returns routed to per period data with pagination support.
+ * @param period The period type (day, week, month)
+ * @param limit Optional limit for results
+ * @param searchQuery Optional search query for user names/emails
+ */
+ async getRoutedToPerPeriodData({
+ period,
+ limit,
+ searchQuery,
+ }: {
+ period: "perDay" | "perWeek" | "perMonth";
+ limit?: number;
+ searchQuery?: string;
+ }) {
+ const dayJsPeriodMap = {
+ perDay: "day",
+ perWeek: "week",
+ perMonth: "month",
+ } as const;
+
+ const dayjsPeriod = dayJsPeriodMap[period];
+ const startDate = dayjs(this.filters.startDate).startOf(dayjsPeriod).toDate();
+ const endDate = dayjs(this.filters.endDate).endOf(dayjsPeriod).toDate();
+
+ const baseConditions = await this.getBaseConditions({ exclude: { createdAt: true } });
+
+ // Build search condition
+ const searchCondition = searchQuery
+ ? Prisma.sql`("bookingUserName" ILIKE ${`%${searchQuery}%`} OR "bookingUserEmail" ILIKE ${`%${searchQuery}%`})`
+ : Prisma.sql`1 = 1`;
+
+ // Get users who have been routed to during the period
+ const usersQuery = await this.prisma.$queryRaw<
+ Array<{
+ id: number;
+ name: string | null;
+ email: string | null;
+ avatarUrl: string | null;
+ }>
+ >`
+ SELECT DISTINCT ON ("bookingUserId")
+ "bookingUserId" as id,
+ "bookingUserName" as name,
+ "bookingUserEmail" as email,
+ "bookingUserAvatarUrl" as "avatarUrl"
+ FROM "RoutingFormResponseDenormalized" rfrd
+ WHERE "bookingUid" IS NOT NULL
+ AND "bookingUserId" IS NOT NULL
+ AND "createdAt" >= ${startDate}
+ AND "createdAt" <= ${endDate}
+ AND ${baseConditions}
+ AND ${searchCondition}
+ ORDER BY "bookingUserId", "createdAt" DESC
+ ${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
+ `;
+
+ const users = usersQuery;
+
+ // Return early if no users found
+ if (users.length === 0) {
+ return {
+ users: {
+ data: [],
+ nextCursor: undefined,
+ },
+ periodStats: {
+ data: [],
+ nextCursor: undefined,
+ },
+ };
+ }
+
+ // Get periods with pagination
+ const periodStats = await this.prisma.$queryRaw<
+ Array<{
+ userId: number;
+ period_start: Date;
+ total: number;
+ }>
+ >`
+ WITH RECURSIVE date_range AS (
+ SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
+ UNION ALL
+ SELECT date + (CASE
+ WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
+ WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
+ WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
+ END)
+ FROM date_range
+ WHERE date + (CASE
+ WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
+ WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
+ WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
+ END) <= date_trunc(${dayjsPeriod}, ${endDate}::timestamp)
+ ),
+ all_users AS (
+ SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
+ ),
+ paginated_periods AS (
+ SELECT date as period_start
+ FROM date_range
+ ORDER BY date ASC
+ ${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
+ ), date_user_combinations AS (
+ SELECT
+ period_start,
+ user_id as "userId"
+ FROM paginated_periods
+ CROSS JOIN all_users
+ ),
+ booking_counts AS (
+ SELECT
+ "bookingUserId",
+ date_trunc(${dayjsPeriod}, "createdAt") as period_start,
+ COUNT(DISTINCT "bookingId")::integer as total
+ FROM "RoutingFormResponseDenormalized" rfrd
+ WHERE "bookingUserId" IN (SELECT user_id FROM all_users)
+ AND "createdAt" >= (SELECT MIN(period_start) FROM paginated_periods)
+ AND "createdAt" < (
+ (SELECT MAX(period_start) FROM paginated_periods)
+ + (CASE
+ WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
+ WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
+ WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
+ END)
+ )
+ AND ${baseConditions}
+ GROUP BY 1, 2
+ )
+ SELECT
+ c."userId",
+ c.period_start,
+ COALESCE(b.total, 0)::integer as total
+ FROM date_user_combinations c
+ LEFT JOIN booking_counts b ON
+ b."bookingUserId" = c."userId" AND
+ b.period_start = c.period_start
+ ORDER BY c.period_start ASC, c."userId" ASC
+ `;
+
+ // Get statistics for the entire period for comparison
+ const statsQuery = await this.prisma.$queryRaw<
+ Array<{
+ userId: number;
+ total_bookings: number;
+ }>
+ >`
+ SELECT
+ "bookingUserId" as "userId",
+ COUNT(DISTINCT "bookingId")::integer as total_bookings
+ FROM "RoutingFormResponseDenormalized" rfrd
+ WHERE "bookingUid" IS NOT NULL
+ AND "createdAt" >= ${startDate}
+ AND "createdAt" <= ${endDate}
+ AND ${baseConditions}
+ GROUP BY "bookingUserId"
+ ORDER BY total_bookings ASC
+ `;
+
+ // Calculate average and median
+ const average =
+ statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
+ const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
+
+ // Create a map of user performance indicators
+ const userPerformance = statsQuery.reduce((acc, stat) => {
+ acc[stat.userId] = {
+ total: stat.total_bookings,
+ performance:
+ stat.total_bookings > average
+ ? "above_average"
+ : stat.total_bookings === median
+ ? "median"
+ : stat.total_bookings < average
+ ? "below_average"
+ : "at_average",
+ };
+ return acc;
+ }, {} as Record);
+
+ return {
+ users: {
+ data: users.map((user) => ({
+ ...user,
+ performance: userPerformance[user.id]?.performance || "no_data",
+ totalBookings: userPerformance[user.id]?.total || 0,
+ })),
+ },
+ periodStats: {
+ data: periodStats,
+ },
+ };
+ }
+
+ /**
+ * Get routed to per period data for CSV export
+ * @param period The period type (day, week, month)
+ * @param searchQuery Optional search query for user names/emails
+ */
+ async getRoutedToPerPeriodCsvData({
+ period,
+ searchQuery,
+ }: {
+ period: "perDay" | "perWeek" | "perMonth";
+ searchQuery?: string;
+ }) {
+ // Call the main method without limit to get all data
+ const data = await this.getRoutedToPerPeriodData({
+ period,
+ searchQuery,
+ // No limit = get all data
+ });
+
+ // Transform to CSV format
+ const userStatsMap = new Map>();
+ data.periodStats.data.forEach((stat) => {
+ const userId = stat.userId;
+ if (!userStatsMap.has(userId)) {
+ userStatsMap.set(userId, {});
+ }
+ userStatsMap.get(userId)![stat.period_start.toISOString()] = stat.total;
+ });
+
+ return data.users.data.map((user) => {
+ const stats = userStatsMap.get(user.id) || {};
+ const periodData = Object.entries(stats).reduce(
+ (acc, [periodStart, total]) => ({
+ ...acc,
+ [`Responses ${dayjs(periodStart).format("YYYY-MM-DD")}`]: total.toString(),
+ }),
+ {} as Record
+ );
+
+ return {
+ "User ID": user.id.toString(),
+ Name: user.name || "",
+ Email: user.email || "",
+ "Total Bookings": user.totalBookings.toString(),
+ Performance: user.performance,
+ ...periodData,
+ };
+ });
+ }
+
+ async getBaseConditions(conditionsOptions?: GetConditionsOptions): Promise {
const authConditions = await this.getAuthorizationConditions();
- const filterConditions = await this.getFilterConditions();
+ const filterConditions = await this.getFilterConditions(conditionsOptions);
if (authConditions && filterConditions) {
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
@@ -413,34 +664,25 @@ export class InsightsRoutingBaseService {
}
}
- async getAuthorizationConditions(): Promise {
- if (this.cachedAuthConditions === undefined) {
- this.cachedAuthConditions = await this.buildAuthorizationConditions();
- }
- return this.cachedAuthConditions;
- }
-
- async getFilterConditions(): Promise {
- if (this.cachedFilterConditions === undefined) {
- this.cachedFilterConditions = await this.buildFilterConditions();
- }
- return this.cachedFilterConditions;
- }
-
- async buildFilterConditions(): Promise {
+ async getFilterConditions(conditionsOptions?: GetConditionsOptions): Promise {
const conditions: Prisma.Sql[] = [];
+ const exclude = (conditionsOptions || {}).exclude || {};
// Date range filtering
- if (this.filters.startDate && this.filters.endDate) {
+ if (!exclude.createdAt) {
conditions.push(
Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp AND "createdAt" <= ${this.filters.endDate}::timestamp`
);
}
+ const columnFilters = (this.filters.columnFilters || []).filter(
+ (filter) => !(exclude.columnFilterIds || []).includes(filter.id)
+ );
+
// Extract specific filters from columnFilters
// Convert columnFilters array to object for easier access
const filtersMap =
- this.filters.columnFilters?.reduce((acc, filter) => {
+ columnFilters.reduce((acc, filter) => {
acc[filter.id] = filter;
return acc;
}, {} as Record>) || {};
@@ -515,9 +757,7 @@ export class InsightsRoutingBaseService {
}
const fieldIdSchema = z.string().uuid();
- const fieldFilters = (this.filters.columnFilters || []).filter(
- (filter) => fieldIdSchema.safeParse(filter.id).success
- );
+ const fieldFilters = (columnFilters || []).filter((filter) => fieldIdSchema.safeParse(filter.id).success);
if (fieldFilters.length > 0) {
const fieldConditions = fieldFilters
@@ -545,7 +785,7 @@ export class InsightsRoutingBaseService {
});
}
- async buildAuthorizationConditions(): Promise {
+ async getAuthorizationConditions(): Promise {
if (!this.options) {
return NOTHING_CONDITION;
}
diff --git a/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts b/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts
index 343f8df36d55a2..e372a1d9f54515 100644
--- a/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts
+++ b/packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts
@@ -5,7 +5,7 @@ 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";
+import { prisma } from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingBaseService as InsightsRoutingService } from "../../service/InsightsRoutingBaseService";
@@ -1507,28 +1507,5 @@ describe("InsightsRoutingService Integration Tests", () => {
await testData.cleanup();
});
-
- it("should return null when no filters are applied", async () => {
- const testData = await createTestData({
- teamRole: MembershipRole.OWNER,
- orgRole: MembershipRole.OWNER,
- });
-
- const service = new InsightsRoutingService({
- prisma,
- options: {
- scope: "user",
- userId: testData.user.id,
- orgId: testData.org.id,
- teamId: undefined,
- },
- filters: {},
- });
-
- const filterConditions = await service.getFilterConditions();
- expect(filterConditions).toBeNull();
-
- await testData.cleanup();
- });
});
});