refactor: Routed to Per Period to use Insights Routing Service (#23031)

* fix: refactor RoutedToPerPeriod to use Insights Routing Service

* clean up

* debounce search and increase stale time

* fix wrongn conflict merge

* clean up DownloadButton

* fix query error

* address feedback

* apply feedback

* remove unnecessary CTE

* fix limit

* make startDate and endDate required

* fix type

* remove irrelevant test case

* address feedback
This commit is contained in:
Eunjae Lee
2025-08-28 22:25:55 +09:00
committed by GitHub
parent baaa04151d
commit 7145a2d640
6 changed files with 368 additions and 721 deletions
@@ -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")}
/>
<div className="flex gap-2">
<div className="w-64">
@@ -147,16 +109,7 @@ function FormCard({
className="w-full"
/>
</div>
<DownloadButton
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={routingFormId}
startDate={startDate}
endDate={endDate}
selectedPeriod={selectedPeriod}
searchQuery={searchQuery}
/>
<DownloadButton selectedPeriod={selectedPeriod} searchQuery={searchQuery} />
</div>
</div>
{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<HTMLDivElement>(null);
const debouncedSearchQuery = useDebounce(searchQuery, 500);
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 { 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<string>();
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<string, number>();
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}>
<div className="mt-6">
<DataTableSkeleton columns={5} columnWidths={[200, 120, 120, 120, 120]} />
</div>
@@ -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}>
<div className="mt-6">
<div
className="scrollbar-thin border-subtle relative overflow-auto rounded-md border"
@@ -405,10 +315,7 @@ export function RoutedToPerPeriod() {
<TableBody className="relative">
{processedData.map((row, index) => {
return (
<TableRow
key={row.id}
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
className="divide-muted divide-x">
<TableRow key={row.id} className="divide-muted divide-x">
<TableCell className="bg-default w-[200px]">
<HoverCard>
<HoverCardTrigger asChild>
@@ -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(),
@@ -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<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
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<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
// 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<number, Array<{ period_start: Date; total: number }>>);
// 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<string, string>
);
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<string, string>[]) {
if (!data.length) return "";
@@ -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";
@@ -942,65 +943,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 };
@@ -1195,3 +1167,25 @@ export async function getEventTypeList({
return eventTypeResult;
}
function objectToCsv(data: Record<string, string>[]) {
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");
}
@@ -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<typeof insightsRoutingServiceOptionsSchema>;
export type InsightsRoutingServiceFilterOptions = {
startDate?: string;
endDate?: string;
startDate: string;
endDate: string;
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
};
@@ -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<Prisma.Sql> {
/**
* 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<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
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<number, Record<string, number>>();
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<string, string>
);
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<Prisma.Sql> {
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<Prisma.Sql> {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
async getFilterConditions(): Promise<Prisma.Sql | null> {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
async buildFilterConditions(): Promise<Prisma.Sql | null> {
async getFilterConditions(conditionsOptions?: GetConditionsOptions): Promise<Prisma.Sql | null> {
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<string, TypedColumnFilter<ColumnFilterType>>) || {};
@@ -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<Prisma.Sql> {
async getAuthorizationConditions(): Promise<Prisma.Sql> {
if (!this.options) {
return NOTHING_CONDITION;
}
@@ -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();
});
});
});