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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user