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(); - }); }); });