+
+
+
+
+ {t("user")}
+ {uniquePeriods.map((period, index) => {
+ const date = period;
+ const today = new Date();
-
-
-
-
-
-
- {t("user")}
- {uniquePeriods.map((period, index) => {
- const date = period;
- const today = new Date();
-
- const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
+ const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
+ return (
+
+
+ {date.toLocaleDateString()}
+
+
+ );
+ })}
+
+
+
+ {processedData.map((row, index) => {
return (
-
-
- {date.toLocaleDateString()}
-
-
- );
- })}
-
-
-
- {processedData.map((row, index) => {
- return (
-
-
-
-
-
-
-
-
-
- {row.name}
-
-
-
- {t("total_bookings_per_period")}:
{row.totalBookings}
- {t("status")}: {getPerformanceBadge(row.performance, t)}
+
+
+
+
+
-
-
-
-
- {uniquePeriods.map((period) => {
- return (
-
- {row.stats[period.toISOString()] === 0 ? null : (
- <>{row.stats[period.toISOString()]}>
- )}
-
- );
- })}
-
- );
- })}
-
-
+
+
+
+
+ {row.name}
+
+
+
+ {t("total_bookings_per_period")}: {row.totalBookings}
+ {t("status")}: {getPerformanceBadge(row.performance, t)}
+
+
+
+
+
+
+ {uniquePeriods.map((period) => {
+ return (
+
+ {row.stats[period.toISOString()] === 0 ? null : (
+ <>{row.stats[period.toISOString()]}>
+ )}
+
+ );
+ })}
+
+ );
+ })}
+
+
+
-
-
-
+
+
+
);
}
diff --git a/packages/features/insights/components/RoutingFunnel.tsx b/packages/features/insights/components/RoutingFunnel.tsx
new file mode 100644
index 0000000000..43a4bdeddf
--- /dev/null
+++ b/packages/features/insights/components/RoutingFunnel.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useColumnFilters } from "@calcom/features/data-table";
+import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+
+import { ChartCard } from "./ChartCard";
+import { RoutingFunnelContent, legend } from "./RoutingFunnelContent";
+import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
+
+export function RoutingFunnel() {
+ const { t } = useLocale();
+ const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
+ const columnFilters = useColumnFilters({
+ exclude: ["createdAt"],
+ });
+ const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
+ {
+ scope,
+ selectedTeamId,
+ startDate,
+ endDate,
+ columnFilters,
+ },
+ {
+ staleTime: 30000,
+ trpc: {
+ context: { skipBatch: true },
+ },
+ }
+ );
+
+ if (isLoading || !isSuccess || !data) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/features/insights/components/RoutingFunnelContent.tsx b/packages/features/insights/components/RoutingFunnelContent.tsx
new file mode 100644
index 0000000000..5ef9a8d98c
--- /dev/null
+++ b/packages/features/insights/components/RoutingFunnelContent.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+interface RoutingFunnelData {
+ name: string;
+ formattedDateFull: string;
+ totalSubmissions: number;
+ successfulRoutings: number;
+ acceptedBookings: number;
+}
+
+interface RoutingFunnelContentProps {
+ data: RoutingFunnelData[];
+}
+
+const COLOR = {
+ TOTAL: "#9AA2F7",
+ SUCCESFUL: "#89CFB5",
+ ACCEPTED: "#F7A1A1",
+};
+
+export const legend = [
+ { label: "Total Submissions", color: COLOR.TOTAL },
+ { label: "Successful Routings", color: COLOR.SUCCESFUL },
+ { label: "Accepted Bookings", color: COLOR.ACCEPTED },
+];
+
+export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
+ const { t } = useLocale();
+
+ return (
+
+
+
+
+
+ } />
+
+
+
+
+
+ );
+}
+
+// Custom Tooltip component
+const CustomTooltip = ({
+ active,
+ payload,
+ label,
+}: {
+ active?: boolean;
+ payload?: Array<{
+ value: number;
+ dataKey: string;
+ name: string;
+ color: string;
+ payload: RoutingFunnelData;
+ }>;
+ label?: string;
+}) => {
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ const totalSubmissions = payload.find((p) => p.dataKey === "totalSubmissions")?.value || 0;
+
+ return (
+
+
{payload[0].payload.formattedDateFull}
+ {payload.map((entry, index: number) => {
+ const value = entry.value;
+ let displayValue = value.toString();
+
+ if (entry.dataKey === "successfulRoutings" || entry.dataKey === "acceptedBookings") {
+ const percentage = totalSubmissions > 0 ? ((value / totalSubmissions) * 100).toFixed(1) : "0";
+ displayValue = `${value} (${percentage}%)`;
+ }
+
+ return (
+
+ {entry.name}: {displayValue}
+
+ );
+ })}
+
+ );
+};
diff --git a/packages/features/insights/components/RoutingFunnelSkeleton.tsx b/packages/features/insights/components/RoutingFunnelSkeleton.tsx
new file mode 100644
index 0000000000..872b322a37
--- /dev/null
+++ b/packages/features/insights/components/RoutingFunnelSkeleton.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { SkeletonText } from "@calcom/ui/components/skeleton";
+
+export function RoutingFunnelSkeleton() {
+ return (
+
+ {/* Chart container */}
+
+ {/* Chart area */}
+
+ {/* Y-axis */}
+
+
+
+
+
+
+
+
+ {/* Chart content */}
+
+ {/* Grid lines */}
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Stacked area chart skeleton */}
+
+ {Array.from({ length: 7 }).map((_, i) => (
+
+ {/* Stacked areas - simulating the three data series */}
+
+
+
+
+ {/* X-axis label */}
+
+
+ ))}
+
+
+
+
+ {/* Legend area */}
+
+
+
+ );
+}
diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts
index 033930c5d4..733c745736 100644
--- a/packages/features/insights/components/index.ts
+++ b/packages/features/insights/components/index.ts
@@ -11,6 +11,7 @@ export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables";
export { PopularEventsTable } from "./PopularEventsTable";
export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
+export { RoutingFunnel } from "./RoutingFunnel";
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
export { RoutingKPICards } from "./RoutingKPICards";
export { TimezoneBadge } from "./TimezoneBadge";
diff --git a/packages/features/insights/hooks/useInsightsOrgTeams.ts b/packages/features/insights/hooks/useInsightsOrgTeams.ts
index 48b773fe77..ca1e260300 100644
--- a/packages/features/insights/hooks/useInsightsOrgTeams.ts
+++ b/packages/features/insights/hooks/useInsightsOrgTeams.ts
@@ -15,6 +15,10 @@ export function useInsightsOrgTeams() {
const teamId = orgTeamsType === "org" ? currentOrgId : orgTeamsType === "team" ? selectedTeamId : undefined;
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
+ // Adding `scope` for InsightsRoutingService
+ // (renaming 'yours' to 'user')
+ const scope: "org" | "team" | "user" = orgTeamsType === "yours" ? "user" : orgTeamsType;
+
return {
orgTeamsType,
setOrgTeamsType,
@@ -23,5 +27,6 @@ export function useInsightsOrgTeams() {
isAll,
teamId,
userId,
+ scope,
};
}
diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts
index f82f168dfe..1530215fbb 100644
--- a/packages/features/insights/hooks/useInsightsParameters.ts
+++ b/packages/features/insights/hooks/useInsightsParameters.ts
@@ -19,7 +19,7 @@ import {
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
export function useInsightsParameters() {
- const { isAll, teamId, userId } = useInsightsOrgTeams();
+ const { isAll, teamId, userId, scope, selectedTeamId } = useInsightsOrgTeams();
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
| number[]
@@ -58,6 +58,8 @@ export function useInsightsParameters() {
isAll,
teamId,
userId,
+ scope,
+ selectedTeamId,
memberUserIds,
memberUserId,
eventTypeId,
diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts
index 015375b486..14354f9a6a 100644
--- a/packages/features/insights/server/events.ts
+++ b/packages/features/insights/server/events.ts
@@ -73,7 +73,7 @@ export interface GetDateRangesParams {
endDate: string;
timeZone: string;
timeView: "day" | "week" | "month" | "year";
- weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday";
+ weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string;
}
class EventsInsights {
diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts
index 273a867990..851e0295b2 100644
--- a/packages/features/insights/server/raw-data.schema.ts
+++ b/packages/features/insights/server/raw-data.schema.ts
@@ -61,3 +61,11 @@ export const routingFormResponsesInputSchema = z.object({
offset: z.number().optional(),
limit: z.number().max(100).optional(),
}) satisfies z.ZodType
;
+
+export const routingRepositoryBaseInputSchema = z.object({
+ scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
+ selectedTeamId: z.number().optional(),
+ startDate: z.string(),
+ endDate: z.string(),
+ columnFilters: z.array(ZColumnFilter).optional(),
+});
diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts
index c141550125..0388d01c76 100644
--- a/packages/features/insights/server/trpc-router.ts
+++ b/packages/features/insights/server/trpc-router.ts
@@ -7,7 +7,9 @@ import {
rawDataInputSchema,
routingFormResponsesInputSchema,
routingFormStatsInputSchema,
+ routingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
+import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRouting";
import type { readonlyPrisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
@@ -1727,6 +1729,37 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
+ getRoutingFunnelData: userBelongsToTeamProcedure
+ .input(routingRepositoryBaseInputSchema)
+ .query(async ({ ctx, input }) => {
+ const timeView = EventsInsights.getTimeView(input.startDate, input.endDate);
+ const dateRanges = EventsInsights.getDateRanges({
+ startDate: input.startDate,
+ endDate: input.endDate,
+ timeZone: ctx.user.timeZone,
+ timeView,
+ weekStart: ctx.user.weekStart,
+ });
+ const insightsRoutingService = new InsightsRoutingService({
+ prisma: ctx.insightsDb,
+ options: {
+ scope: input.scope,
+ teamId: input.selectedTeamId,
+ userId: ctx.user.id,
+ orgId: ctx.user.organizationId,
+ },
+ filters: {
+ startDate: input.startDate,
+ endDate: input.endDate,
+ columnFilters: input.columnFilters,
+ },
+ });
+ try {
+ return await insightsRoutingService.getRoutingFunnelData(dateRanges);
+ } catch (e) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+ }
+ }),
});
async function getEventTypeList({
diff --git a/packages/features/package.json b/packages/features/package.json
index 8274575f7b..47d5a8224d 100644
--- a/packages/features/package.json
+++ b/packages/features/package.json
@@ -22,6 +22,7 @@
"lexical": "^0.9.0",
"react-select": "^5.7.0",
"react-sticky-box": "^2.0.4",
+ "recharts": "^3.0.2",
"stripe-event-types": "^3.1.0",
"web-push": "^3.6.7",
"zustand": "^4.3.2"
diff --git a/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
index 400e90dd58..13be9b42b8 100644
--- a/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
+++ b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
@@ -1,12 +1,23 @@
import type { Team, User, Membership } from "@prisma/client";
+import { Prisma } from "@prisma/client";
import { randomUUID } from "crypto";
import { describe, expect, it } from "vitest";
+import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingService } from "../../service/insightsRouting";
+// SQL condition constants for testing
+const NOTHING_CONDITION = Prisma.sql`1=0`;
+
+// Helper function to create default filters for testing
+const createDefaultFilters = () => ({
+ startDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
+ endDate: new Date().toISOString(),
+});
+
// Helper function to create unique test data
async function createTestData({
teamRole = MembershipRole.MEMBER,
@@ -240,10 +251,11 @@ describe("InsightsRoutingService Integration Tests", () => {
const service = new InsightsRoutingService({
prisma,
options: null as any,
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({ id: -1 });
+ expect(conditions).toEqual(NOTHING_CONDITION);
});
it("should return NOTHING for non-owner/admin user", async () => {
@@ -274,11 +286,13 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "org",
userId: regularUser.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({ id: -1 });
+ expect(conditions).toEqual(NOTHING_CONDITION);
// Clean up
await prisma.membership.delete({
@@ -302,17 +316,23 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({
- AND: [
- {
- formUserId: testData.user.id,
- },
- ],
- });
+ expect(conditions).toMatchInlineSnapshot(`
+ e {
+ "strings": [
+ ""formUserId" = ",
+ " AND "formTeamId" IS NULL",
+ ],
+ "values": [
+ ${testData.user.id},
+ ],
+ }
+ `);
await testData.cleanup();
});
@@ -331,16 +351,11 @@ describe("InsightsRoutingService Integration Tests", () => {
orgId: testData.org.id,
teamId: testData.team.id,
},
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({
- AND: [
- {
- formTeamId: testData.team.id,
- },
- ],
- });
+ expect(conditions).toEqual(Prisma.sql`"formTeamId" = ${testData.team.id}`);
// Clean up
await testData.cleanup();
@@ -366,20 +381,31 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({
- AND: [
- {
- formTeamId: {
- in: [testData.org.id, testData.team.id, team2.id, team3.id],
- },
- },
- ],
- });
+ expect(conditions).toMatchInlineSnapshot(`
+ e {
+ "strings": [
+ "("formTeamId" = ANY(",
+ ")) OR ("formUserId" = ",
+ " AND "formTeamId" IS NULL)",
+ ],
+ "values": [
+ [
+ ${testData.org.id},
+ ${testData.team.id},
+ ${team2.id},
+ ${team3.id},
+ ],
+ ${testData.user.id},
+ ],
+ }
+ `);
await testData.cleanup();
});
@@ -408,10 +434,11 @@ describe("InsightsRoutingService Integration Tests", () => {
orgId: testData.org.id,
teamId: unrelatedTeam.id,
},
+ filters: createDefaultFilters(),
});
const conditions = await service.getAuthorizationConditions();
- expect(conditions).toEqual({ AND: [{ id: -1 }] });
+ expect(conditions).toEqual(NOTHING_CONDITION);
// Clean up
await prisma.team.delete({
@@ -422,7 +449,7 @@ describe("InsightsRoutingService Integration Tests", () => {
});
describe("Filter Conditions", () => {
- it("should return null when no filters are provided", async () => {
+ it("should build filter conditions when dates are provided", async () => {
const testData = await createTestData();
const service = new InsightsRoutingService({
@@ -431,11 +458,18 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ startDate: "2024-01-01",
+ endDate: "2024-12-31",
},
});
const conditions = await service.getFilterConditions();
- expect(conditions).toBeNull();
+ expect(conditions).toEqual(
+ Prisma.sql`"createdAt" >= ${"2024-01-01"}::timestamp AND "createdAt" <= ${"2024-12-31"}::timestamp`
+ );
await testData.cleanup();
});
@@ -454,18 +488,14 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
// First call should build conditions
const conditions1 = await service.getAuthorizationConditions();
- expect(conditions1).toEqual({
- AND: [
- {
- formUserId: testData.user.id,
- },
- ],
- });
+ expect(conditions1).toEqual(Prisma.sql`"formUserId" = ${testData.user.id} AND "formTeamId" IS NULL`);
// Second call should use cached conditions
const conditions2 = await service.getAuthorizationConditions();
@@ -484,12 +514,14 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
// First call should build conditions
const conditions1 = await service.getFilterConditions();
- expect(conditions1).toBeNull();
+ expect(conditions1).toBeDefined();
// Second call should use cached conditions
const conditions2 = await service.getFilterConditions();
@@ -499,42 +531,136 @@ describe("InsightsRoutingService Integration Tests", () => {
});
});
- describe("findMany", () => {
+ describe("getBaseConditions", () => {
it("should combine authorization and filter conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
+ const filters = createDefaultFilters();
+ const dateCondition = Prisma.sql`"createdAt" >= ${filters.startDate}::timestamp AND "createdAt" <= ${filters.endDate}::timestamp`;
+
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters,
});
- const results = await service.findMany({
- select: {
- id: true,
- formName: true,
- },
- });
-
- // Should return the user form response since it matches the authorization conditions
- expect(results).toHaveLength(1);
- expect(results[0]?.id).toBe(testData.formResponse.id);
+ const results = await service.getBaseConditions();
+ expect(results).toEqual(
+ Prisma.sql`("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition})`
+ );
await testData.cleanup();
});
- it("should filter out unauthorized responses", async () => {
+ it("should include personal form in org scope", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
+ const personalForm = await prisma.app_RoutingForms_Form.create({
+ data: {
+ name: "My Personal Form",
+ userId: testData.user.id,
+ teamId: null,
+ fields: [
+ {
+ id: "text-field-id",
+ type: "text",
+ label: "Name",
+ required: true,
+ },
+ ],
+ },
+ });
+
+ const personalFormResponse = await prisma.app_RoutingForms_FormResponse.create({
+ data: {
+ formFillerId: `personal-form-filler-${randomUUID()}`,
+ formId: personalForm.id,
+ response: {
+ "text-field-id": {
+ label: "Name",
+ value: "Personal Form Response",
+ },
+ },
+ },
+ });
+
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "org",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: createDefaultFilters(),
+ });
+
+ const baseConditions = await service.getBaseConditions();
+ const results = await prisma.$queryRaw>`
+ SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
+ `;
+
+ // Should only return the authorized user's form response
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe(testData.formResponse.id);
+ expect(results[1]?.id).toBe(personalFormResponse.id);
+
+ // Clean up
+ await prisma.app_RoutingForms_FormResponse.delete({
+ where: { id: personalFormResponse.id },
+ });
+ await prisma.app_RoutingForms_Form.delete({
+ where: { id: personalForm.id },
+ });
+ await testData.cleanup();
+ });
+
+ it("should get personal form response", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const personalForm = await prisma.app_RoutingForms_Form.create({
+ data: {
+ name: "My Personal Form",
+ userId: testData.user.id,
+ teamId: null,
+ fields: [
+ {
+ id: "text-field-id",
+ type: "text",
+ label: "Name",
+ required: true,
+ },
+ ],
+ },
+ });
+
+ const personalFormResponse = await prisma.app_RoutingForms_FormResponse.create({
+ data: {
+ formFillerId: `personal-form-filler-${randomUUID()}`,
+ formId: personalForm.id,
+ response: {
+ "text-field-id": {
+ label: "Name",
+ value: "Personal Form Response",
+ },
+ },
+ },
+ });
+
// Create another user and form
const otherUser = await prisma.user.create({
data: {
@@ -579,27 +705,27 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
- const results = await service.findMany({
- select: {
- id: true,
- formName: true,
- },
- });
+ const baseConditions = await service.getBaseConditions();
+ const results = await prisma.$queryRaw>`
+ SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
+ `;
// Should only return the authorized user's form response
expect(results).toHaveLength(1);
- expect(results[0]?.id).toBe(testData.formResponse.id);
+ expect(results[0]?.id).toBe(personalFormResponse.id);
expect(results[0]?.id).not.toBe(otherFormResponse.id);
// Clean up
- await prisma.app_RoutingForms_FormResponse.delete({
- where: { id: otherFormResponse.id },
+ await prisma.app_RoutingForms_FormResponse.deleteMany({
+ where: { id: { in: [personalFormResponse.id, otherFormResponse.id] } },
});
- await prisma.app_RoutingForms_Form.delete({
- where: { id: otherForm.id },
+ await prisma.app_RoutingForms_Form.deleteMany({
+ where: { id: { in: [personalForm.id, otherForm.id] } },
});
await prisma.user.delete({
where: { id: otherUser.id },
@@ -669,15 +795,15 @@ describe("InsightsRoutingService Integration Tests", () => {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
+ teamId: undefined,
},
+ filters: createDefaultFilters(),
});
- const results = await service.findMany({
- select: {
- id: true,
- formName: true,
- },
- });
+ const baseConditions = await service.getBaseConditions();
+ const results = await prisma.$queryRaw>`
+ SELECT id FROM "RoutingFormResponseDenormalized" WHERE ${baseConditions}
+ `;
// Should return both form responses (original user's and team member's)
expect(results).toHaveLength(2);
@@ -702,4 +828,574 @@ describe("InsightsRoutingService Integration Tests", () => {
await testData.cleanup();
});
});
+
+ describe("columnFilters", () => {
+ it("should handle empty columnFilters", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`"createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${defaultFilters.endDate}::timestamp`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should handle undefined columnFilters", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: undefined,
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`"createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${defaultFilters.endDate}::timestamp`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by booking status order (multi-select)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingStatusOrder",
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: ["pending", "accepted"],
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingStatusOrder" = ANY(${["pending", "accepted"]}))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by booking assignment reason (text)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingAssignmentReason",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "contains", operand: "manual" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by booking UID (text)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingUid",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "equals", operand: "test-booking-123" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingUid" = ${"test-booking-123"})`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by member user IDs (multi-select)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingUserId",
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: [testData.user.id, 999],
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingUserId" = ANY(${[testData.user.id, 999]}))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by booking attendees (text)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingAttendees",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "contains", operand: "john@example.com" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND (EXISTS (
+ SELECT 1 FROM "Booking" b
+ INNER JOIN "Attendee" a ON a."bookingId" = b."id"
+ WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
+ AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`})
+ ))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by custom form fields (text)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "custom-field-id",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "equals", operand: "test value" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND (EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${"custom-field-id"}
+ AND rrf."valueString" = ${"test value"}
+ ))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by custom form fields (multi-select)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "custom-multi-field-id",
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: ["option1", "option2"],
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND (EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${"custom-multi-field-id"}
+ AND rrf."valueStringArray" @> ${["option1", "option2"]}
+ ))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should combine multiple filters with AND", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingStatusOrder",
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: ["pending"],
+ },
+ },
+ {
+ id: "bookingAssignmentReason",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "contains", operand: "manual" },
+ },
+ },
+ {
+ id: "custom-field-id",
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "equals", operand: "test" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`((("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingStatusOrder" = ANY(${[
+ "pending",
+ ]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${"custom-field-id"}
+ AND rrf."valueString" = ${"test"}
+ ))`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by form ID (single-select)", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "formId",
+ value: {
+ type: ColumnFilterType.SINGLE_SELECT,
+ data: "form-123",
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("formId" = ${"form-123"})`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should filter by form ID with other filters", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "formId",
+ value: {
+ type: ColumnFilterType.SINGLE_SELECT,
+ data: "form-456",
+ },
+ },
+ {
+ id: "bookingStatusOrder",
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: ["pending", "accepted"],
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`(("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingStatusOrder" = ANY(${[
+ "pending",
+ "accepted",
+ ]}))) AND ("formId" = ${"form-456"})`
+ );
+
+ await testData.cleanup();
+ });
+
+ it("should exclude system filters from form field processing", async () => {
+ const testData = await createTestData({
+ teamRole: MembershipRole.OWNER,
+ orgRole: MembershipRole.OWNER,
+ });
+
+ const defaultFilters = createDefaultFilters();
+ const service = new InsightsRoutingService({
+ prisma,
+ options: {
+ scope: "user",
+ userId: testData.user.id,
+ orgId: testData.org.id,
+ teamId: undefined,
+ },
+ filters: {
+ ...defaultFilters,
+ columnFilters: [
+ {
+ id: "bookingStatusOrder", // System filter
+ value: {
+ type: ColumnFilterType.MULTI_SELECT,
+ data: ["pending"],
+ },
+ },
+ {
+ id: "custom-field-id", // Custom field filter
+ value: {
+ type: ColumnFilterType.TEXT,
+ data: { operator: "equals", operand: "test" },
+ },
+ },
+ ],
+ },
+ });
+
+ const filterConditions = await service.getFilterConditions();
+ expect(filterConditions).toEqual(
+ Prisma.sql`(("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
+ defaultFilters.endDate
+ }::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${"custom-field-id"}
+ AND rrf."valueString" = ${"test"}
+ ))`
+ );
+
+ 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();
+ });
+ });
});
diff --git a/packages/lib/server/service/insightsRouting.ts b/packages/lib/server/service/insightsRouting.ts
index e28d0248fb..435df66bba 100644
--- a/packages/lib/server/service/insightsRouting.ts
+++ b/packages/lib/server/service/insightsRouting.ts
@@ -1,6 +1,16 @@
-import type { Prisma } from "@prisma/client";
+import { Prisma } from "@prisma/client";
import { z } from "zod";
+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";
+import {
+ isMultiSelectFilterValue,
+ isTextFilterValue,
+ isNumberFilterValue,
+ isSingleSelectFilterValue,
+} from "@calcom/features/data-table/lib/utils";
+import type { DateRange } from "@calcom/features/insights/server/events";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -26,22 +36,29 @@ export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope",
}),
]);
+export type InsightsRoutingServicePublicOptions = {
+ scope: "user" | "org" | "team";
+ userId: number;
+ orgId: number | null;
+ teamId: number | undefined;
+};
+
export type InsightsRoutingServiceOptions = z.infer;
export type InsightsRoutingServiceFilterOptions = {
- // Empty for now
+ startDate?: string;
+ endDate?: string;
+ columnFilters?: TypedColumnFilter[];
};
-const NOTHING = {
- id: -1,
-} as const;
+const NOTHING_CONDITION = Prisma.sql`1=0`;
export class InsightsRoutingService {
private prisma: typeof readonlyPrisma;
private options: InsightsRoutingServiceOptions | null;
- private filters?: InsightsRoutingServiceFilterOptions;
- private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
- private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
+ private filters: InsightsRoutingServiceFilterOptions;
+ private cachedAuthConditions?: Prisma.Sql;
+ private cachedFilterConditions?: Prisma.Sql | null;
constructor({
prisma,
@@ -49,8 +66,8 @@ export class InsightsRoutingService {
filters,
}: {
prisma: typeof readonlyPrisma;
- options: InsightsRoutingServiceOptions;
- filters?: InsightsRoutingServiceFilterOptions;
+ options: InsightsRoutingServicePublicOptions;
+ filters: InsightsRoutingServiceFilterOptions;
}) {
this.prisma = prisma;
@@ -60,71 +77,254 @@ export class InsightsRoutingService {
this.filters = filters;
}
- async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
- const authConditions = await this.getAuthorizationConditions();
- const filterConditions = await this.getFilterConditions();
+ /**
+ * Returns routing funnel data split by the given date ranges.
+ * @param dateRanges Array of { startDate, endDate, formattedDate }
+ */
+ async getRoutingFunnelData(dateRanges: DateRange[]) {
+ if (!dateRanges.length) return [];
- return this.prisma.routingFormResponseDenormalized.findMany({
- ...findManyArgs,
- where: {
- ...findManyArgs.where,
- AND: [authConditions, filterConditions].filter(
- (c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
- ),
- },
+ // Validate date formats
+ for (const range of dateRanges) {
+ if (isNaN(Date.parse(range.startDate)) || isNaN(Date.parse(range.endDate))) {
+ throw new Error(`Invalid date format in range: ${range.startDate} - ${range.endDate}`);
+ }
+ }
+
+ const baseConditions = await this.getBaseConditions();
+
+ // Build the CASE statements for each date range with proper date casting
+ const caseStatements = dateRanges
+ .map(
+ (dateRange) => Prisma.sql`
+ WHEN "createdAt" >= ${dateRange.startDate}::timestamp AND "createdAt" <= ${dateRange.endDate}::timestamp THEN ${dateRange.formattedDate}
+ `
+ )
+ .reduce((acc, curr) => Prisma.sql`${acc} ${curr}`);
+
+ // Single query to get all data grouped by date ranges using CTE
+ const results = await this.prisma.$queryRaw<
+ Array<{
+ dateRange: string | null;
+ totalSubmissions: bigint;
+ successfulRoutings: bigint;
+ acceptedBookings: bigint;
+ }>
+ >`
+ WITH date_ranged_data AS (
+ SELECT
+ CASE ${caseStatements}
+ ELSE NULL
+ END as "dateRange",
+ "bookingUid",
+ "bookingStatus"
+ FROM "RoutingFormResponseDenormalized"
+ WHERE ${baseConditions}
+ )
+ SELECT
+ "dateRange",
+ COUNT(*) as "totalSubmissions",
+ COUNT(CASE WHEN "bookingUid" IS NOT NULL THEN 1 END) as "successfulRoutings",
+ COUNT(CASE WHEN "bookingStatus" NOT IN ('cancelled', 'rejected') THEN 1 END) as "acceptedBookings"
+ FROM date_ranged_data
+ WHERE "dateRange" IS NOT NULL
+ GROUP BY "dateRange"
+ ORDER BY "dateRange"
+ `;
+
+ // Create a map of results by dateRange for easy lookup
+ const resultsMap = new Map(
+ results
+ .filter((row): row is typeof row & { dateRange: string } => row.dateRange !== null)
+ .map((row) => [
+ row.dateRange,
+ {
+ name: row.dateRange,
+ totalSubmissions: Number(row.totalSubmissions),
+ successfulRoutings: Number(row.successfulRoutings),
+ acceptedBookings: Number(row.acceptedBookings),
+ },
+ ])
+ );
+
+ // Return all date ranges, filling with 0 values for missing data
+ return dateRanges.map((dateRange) => {
+ const existingData = resultsMap.get(dateRange.formattedDate);
+ const data = existingData || {
+ name: dateRange.formattedDate,
+ totalSubmissions: 0,
+ successfulRoutings: 0,
+ acceptedBookings: 0,
+ };
+
+ return {
+ ...data,
+ formattedDateFull: dateRange.formattedDateFull,
+ };
});
}
- async getAuthorizationConditions(): Promise {
+ async getBaseConditions(): Promise {
+ const authConditions = await this.getAuthorizationConditions();
+ const filterConditions = await this.getFilterConditions();
+
+ if (authConditions && filterConditions) {
+ return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
+ } else if (authConditions) {
+ return authConditions;
+ } else if (filterConditions) {
+ return filterConditions;
+ } else {
+ return NOTHING_CONDITION;
+ }
+ }
+
+ async getAuthorizationConditions(): Promise {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
- async getFilterConditions(): Promise {
+ async getFilterConditions(): Promise {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
- async buildFilterConditions(): Promise {
- // Empty for now
- return null;
+ async buildFilterConditions(): Promise {
+ const conditions: Prisma.Sql[] = [];
+
+ // Date range filtering
+ if (this.filters.startDate && this.filters.endDate) {
+ conditions.push(
+ Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp AND "createdAt" <= ${this.filters.endDate}::timestamp`
+ );
+ }
+
+ // Extract specific filters from columnFilters
+ // Convert columnFilters array to object for easier access
+ const filtersMap =
+ this.filters.columnFilters?.reduce((acc, filter) => {
+ acc[filter.id] = filter;
+ return acc;
+ }, {} as Record>) || {};
+
+ // Extract booking status order filter
+ const bookingStatusOrder = filtersMap["bookingStatusOrder"];
+ if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) {
+ const statusCondition = makeSqlCondition(bookingStatusOrder.value);
+ if (statusCondition) {
+ conditions.push(Prisma.sql`"bookingStatusOrder" ${statusCondition}`);
+ }
+ }
+
+ // Extract booking assignment reason filter
+ const bookingAssignmentReason = filtersMap["bookingAssignmentReason"];
+ if (bookingAssignmentReason && isTextFilterValue(bookingAssignmentReason.value)) {
+ const reasonCondition = makeSqlCondition(bookingAssignmentReason.value);
+ if (reasonCondition) {
+ conditions.push(Prisma.sql`"bookingAssignmentReason" ${reasonCondition}`);
+ }
+ }
+
+ // Extract booking UID filter
+ const bookingUid = filtersMap["bookingUid"];
+ if (bookingUid && isTextFilterValue(bookingUid.value)) {
+ const uidCondition = makeSqlCondition(bookingUid.value);
+ if (uidCondition) {
+ conditions.push(Prisma.sql`"bookingUid" ${uidCondition}`);
+ }
+ }
+
+ // Extract booking attendees filter
+ const bookingAttendees = filtersMap["bookingAttendees"];
+ if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) {
+ const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value);
+ if (attendeesCondition) {
+ conditions.push(attendeesCondition);
+ }
+ }
+
+ // Extract member user IDs filter (multi-select)
+ const memberUserIds = filtersMap["bookingUserId"];
+ if (memberUserIds && isMultiSelectFilterValue(memberUserIds.value)) {
+ conditions.push(Prisma.sql`"bookingUserId" = ANY(${memberUserIds.value.data})`);
+ }
+
+ // Extract form ID filter (single-select)
+ const formId = filtersMap["formId"];
+ if (formId && isSingleSelectFilterValue(formId.value)) {
+ const formIdCondition = makeSqlCondition(formId.value);
+ if (formIdCondition) {
+ conditions.push(Prisma.sql`"formId" ${formIdCondition}`);
+ }
+ }
+
+ // Extract form field filters (exclude the system filters we already processed)
+ const alreadyHandledFilters = [
+ "bookingStatusOrder",
+ "bookingAssignmentReason",
+ "bookingUid",
+ "bookingAttendees",
+ "bookingUserId",
+ "formId",
+ ];
+
+ const fieldFilters = (this.filters.columnFilters || []).filter(
+ (filter) => !alreadyHandledFilters.includes(filter.id)
+ );
+
+ if (fieldFilters.length > 0) {
+ const fieldConditions = fieldFilters
+ .map((fieldFilter) => this.buildFormFieldSqlCondition(fieldFilter.id, fieldFilter.value))
+ .filter((condition): condition is Prisma.Sql => condition !== null);
+
+ if (fieldConditions.length > 0) {
+ // Join multiple field conditions with AND
+ const joinedConditions = fieldConditions.reduce((acc, condition, index) => {
+ if (index === 0) return condition;
+ return Prisma.sql`(${acc}) AND (${condition})`;
+ });
+ conditions.push(joinedConditions);
+ }
+ }
+
+ if (conditions.length === 0) {
+ return null;
+ }
+
+ // Join all conditions with AND
+ return conditions.reduce((acc, condition, index) => {
+ if (index === 0) return condition;
+ return Prisma.sql`(${acc}) AND (${condition})`;
+ });
}
- async buildAuthorizationConditions(): Promise {
+ async buildAuthorizationConditions(): Promise {
if (!this.options) {
- return NOTHING;
+ return NOTHING_CONDITION;
}
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
if (!isOwnerOrAdmin) {
- return NOTHING;
+ return NOTHING_CONDITION;
}
- const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
-
if (this.options.scope === "user") {
- conditions.push({
- formUserId: this.options.userId,
- });
+ return Prisma.sql`"formUserId" = ${this.options.userId} AND "formTeamId" IS NULL`;
} else if (this.options.scope === "org") {
- conditions.push(await this.buildOrgAuthorizationCondition(this.options));
+ return await this.buildOrgAuthorizationCondition(this.options);
} else if (this.options.scope === "team") {
- conditions.push(await this.buildTeamAuthorizationCondition(this.options));
+ return await this.buildTeamAuthorizationCondition(this.options);
} else {
- return NOTHING;
+ return NOTHING_CONDITION;
}
-
- return {
- AND: conditions,
- };
}
private async buildOrgAuthorizationCondition(
options: Extract
- ): Promise {
+ ): Promise {
// Get all teams from the organization
const teamRepo = new TeamRepository(this.prisma);
const teamsFromOrg = await teamRepo.findAllByParentId({
@@ -132,16 +332,14 @@ export class InsightsRoutingService {
select: { id: true },
});
- return {
- formTeamId: {
- in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
- },
- };
+ const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
+
+ return Prisma.sql`("formTeamId" = ANY(${teamIds})) OR ("formUserId" = ${options.userId} AND "formTeamId" IS NULL)`;
}
private async buildTeamAuthorizationCondition(
options: Extract
- ): Promise {
+ ): Promise {
const teamRepo = new TeamRepository(this.prisma);
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
id: options.teamId,
@@ -149,12 +347,10 @@ export class InsightsRoutingService {
select: { id: true },
});
if (!childTeamOfOrg) {
- return NOTHING;
+ return NOTHING_CONDITION;
}
- return {
- formTeamId: options.teamId,
- };
+ return Prisma.sql`"formTeamId" = ${options.teamId}`;
}
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise {
@@ -167,4 +363,71 @@ export class InsightsRoutingService {
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
);
}
+
+ private buildFormFieldSqlCondition(fieldId: string, filterValue: FilterValue): Prisma.Sql | null {
+ if (isMultiSelectFilterValue(filterValue)) {
+ // For multi-select fields, check if the field contains any of the selected values
+ return Prisma.sql`EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${fieldId}
+ AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
+ )`;
+ } else if (isSingleSelectFilterValue(filterValue)) {
+ return Prisma.sql`EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${fieldId}
+ AND rrf."valueString" = ${filterValue.data}
+ )`;
+ } else if (isTextFilterValue(filterValue)) {
+ const condition = makeSqlCondition(filterValue);
+ if (!condition) {
+ return null;
+ }
+ return Prisma.sql`EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${fieldId}
+ AND rrf."valueString" ${condition}
+ )`;
+ } else if (isNumberFilterValue(filterValue)) {
+ const condition = makeSqlCondition(filterValue);
+ if (!condition) {
+ return null;
+ }
+ return Prisma.sql`EXISTS (
+ SELECT 1 FROM "RoutingFormResponseField" rrf
+ WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
+ AND rrf."fieldId" = ${fieldId}
+ AND rrf."valueNumber" ${condition}
+ )`;
+ } else {
+ return null;
+ }
+ }
+
+ private buildAttendeeSqlCondition(filterValue: TextFilterValue): Prisma.Sql | null {
+ if (!isTextFilterValue(filterValue)) {
+ return null;
+ }
+
+ const textCondition = makeSqlCondition(filterValue);
+
+ if (!textCondition) {
+ return null;
+ }
+
+ const nameCondition = Prisma.sql`a.name ${textCondition}`;
+ const emailCondition = Prisma.sql`a.email ${textCondition}`;
+
+ const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`;
+
+ return Prisma.sql`EXISTS (
+ SELECT 1 FROM "Booking" b
+ INNER JOIN "Attendee" a ON a."bookingId" = b."id"
+ WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
+ AND (${combinedCondition})
+ )`;
+ }
}
diff --git a/yarn.lock b/yarn.lock
index 7489ef9d30..a7ba5789ed 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3177,6 +3177,7 @@ __metadata:
lexical: ^0.9.0
react-select: ^5.7.0
react-sticky-box: ^2.0.4
+ recharts: ^3.0.2
stripe-event-types: ^3.1.0
web-push: ^3.6.7
zustand: ^4.3.2
@@ -14074,6 +14075,28 @@ __metadata:
languageName: node
linkType: hard
+"@reduxjs/toolkit@npm:1.x.x || 2.x.x":
+ version: 2.8.2
+ resolution: "@reduxjs/toolkit@npm:2.8.2"
+ dependencies:
+ "@standard-schema/spec": ^1.0.0
+ "@standard-schema/utils": ^0.3.0
+ immer: ^10.0.3
+ redux: ^5.0.1
+ redux-thunk: ^3.1.0
+ reselect: ^5.1.0
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+ checksum: 94bb4714ff734ea2c0e632a29b0b165507321be097258e6bd66dddca81760610defcc9eff3a46d35e1de010402d24078a7e74648cbc94d5eacfc84dcb01c7161
+ languageName: node
+ linkType: hard
+
"@repeaterjs/repeater@npm:^3.0.4, @repeaterjs/repeater@npm:^3.0.6":
version: 3.0.6
resolution: "@repeaterjs/repeater@npm:3.0.6"
@@ -16151,6 +16174,13 @@ __metadata:
languageName: node
linkType: hard
+"@standard-schema/utils@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "@standard-schema/utils@npm:0.3.0"
+ checksum: 7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51
+ languageName: node
+ linkType: hard
+
"@storybook/blocks@npm:^7.6.3":
version: 7.6.3
resolution: "@storybook/blocks@npm:7.6.3"
@@ -18331,6 +18361,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/use-sync-external-store@npm:^0.0.6":
+ version: 0.0.6
+ resolution: "@types/use-sync-external-store@npm:0.0.6"
+ checksum: a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049
+ languageName: node
+ linkType: hard
+
"@types/uuid@npm:8.3.1":
version: 8.3.1
resolution: "@types/uuid@npm:8.3.1"
@@ -23997,7 +24034,7 @@ __metadata:
languageName: node
linkType: hard
-"decimal.js-light@npm:^2.4.1":
+"decimal.js-light@npm:^2.4.1, decimal.js-light@npm:^2.5.1":
version: 2.5.1
resolution: "decimal.js-light@npm:2.5.1"
checksum: f5a2c7eac1c4541c8ab8a5c8abea64fc1761cefc7794bd5f8afd57a8a78d1b51785e0c4e4f85f4895a043eaa90ddca1edc3981d1263eb6ddce60f32bf5fe66c9
@@ -25468,6 +25505,18 @@ __metadata:
languageName: node
linkType: hard
+"es-toolkit@npm:^1.39.3":
+ version: 1.39.5
+ resolution: "es-toolkit@npm:1.39.5"
+ dependenciesMeta:
+ "@trivago/prettier-plugin-sort-imports@4.3.0":
+ unplugged: true
+ prettier-plugin-sort-re-exports@0.0.1:
+ unplugged: true
+ checksum: 92295637b10fb717c8b2cfd2423caa839a10636ed49d7b79bb2acfb364fe43769aa3c19fa9cc54e2638b6fe8acdc5035611096e4a53bdda65de1f8e62b02a2ad
+ languageName: node
+ linkType: hard
+
"es6-promise@npm:^4.2.4":
version: 4.2.8
resolution: "es6-promise@npm:4.2.8"
@@ -26794,6 +26843,13 @@ __metadata:
languageName: node
linkType: hard
+"eventemitter3@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "eventemitter3@npm:5.0.1"
+ checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8
+ languageName: node
+ linkType: hard
+
"events@npm:^3.0.0, events@npm:^3.1.0, events@npm:^3.2.0, events@npm:^3.3.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
@@ -29919,6 +29975,13 @@ __metadata:
languageName: node
linkType: hard
+"immer@npm:^10.0.3, immer@npm:^10.1.1":
+ version: 10.1.1
+ resolution: "immer@npm:10.1.1"
+ checksum: 07c67970b7d22aded73607193d84861bf786f07d47f7d7c98bb10016c7a88f6654ad78ae1e220b3c623695b133aabbf24f5eb8d9e8060cff11e89ccd81c9c10b
+ languageName: node
+ linkType: hard
+
"immutable@npm:^3.8.2":
version: 3.8.2
resolution: "immutable@npm:3.8.2"
@@ -39697,6 +39760,25 @@ __metadata:
languageName: node
linkType: hard
+"react-redux@npm:8.x.x || 9.x.x":
+ version: 9.2.0
+ resolution: "react-redux@npm:9.2.0"
+ dependencies:
+ "@types/use-sync-external-store": ^0.0.6
+ use-sync-external-store: ^1.4.0
+ peerDependencies:
+ "@types/react": ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ redux:
+ optional: true
+ checksum: 96dfe2929561d7c98d4443722738e4595f08758bde27b7bc20cd98ba9b0dfe9b81b9fa17b6888be94a0c1d2d1305397ae493a8219698536d011a941589eb82bd
+ languageName: node
+ linkType: hard
+
"react-redux@npm:^7.2.2":
version: 7.2.8
resolution: "react-redux@npm:7.2.8"
@@ -40298,6 +40380,29 @@ __metadata:
languageName: node
linkType: hard
+"recharts@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "recharts@npm:3.0.2"
+ dependencies:
+ "@reduxjs/toolkit": 1.x.x || 2.x.x
+ clsx: ^2.1.1
+ decimal.js-light: ^2.5.1
+ es-toolkit: ^1.39.3
+ eventemitter3: ^5.0.1
+ immer: ^10.1.1
+ react-redux: 8.x.x || 9.x.x
+ reselect: 5.1.1
+ tiny-invariant: ^1.3.3
+ use-sync-external-store: ^1.2.2
+ victory-vendor: ^37.0.2
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 7fe805a8a4aedf89ee551be53fbdf20a5690118db8d0d55b5794665c83c5568d30802645a177b5d38e602fa8bc3a04bdaecb123df7c2c378e46773526538094e
+ languageName: node
+ linkType: hard
+
"rechoir@npm:^0.6.2":
version: 0.6.2
resolution: "rechoir@npm:0.6.2"
@@ -40420,6 +40525,15 @@ __metadata:
languageName: node
linkType: hard
+"redux-thunk@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "redux-thunk@npm:3.1.0"
+ peerDependencies:
+ redux: ^5.0.0
+ checksum: bea96f8233975aad4c9f24ca1ffd08ac7ec91eaefc26e7ba9935544dc55d7f09ba2aa726676dab53dc79d0c91e8071f9729cddfea927f4c41839757d2ade0f50
+ languageName: node
+ linkType: hard
+
"redux@npm:^4.0.0":
version: 4.1.2
resolution: "redux@npm:4.1.2"
@@ -40438,6 +40552,13 @@ __metadata:
languageName: node
linkType: hard
+"redux@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "redux@npm:5.0.1"
+ checksum: e74affa9009dd5d994878b9a1ce30d6569d986117175056edb003de2651c05b10fe7819d6fa94aea1a94de9a82f252f986547f007a2fbeb35c317a2e5f5ecf2c
+ languageName: node
+ linkType: hard
+
"reflect-metadata@npm:0.2.2":
version: 0.2.2
resolution: "reflect-metadata@npm:0.2.2"
@@ -40823,6 +40944,13 @@ __metadata:
languageName: node
linkType: hard
+"reselect@npm:5.1.1, reselect@npm:^5.1.0":
+ version: 5.1.1
+ resolution: "reselect@npm:5.1.1"
+ checksum: 5d32d48be29071ddda21a775945c2210cf4ca3fccde1c4a0e1582ac3bf99c431c6c2330ef7ca34eae4c06feea617e7cb2c275c4b33ccf9a930836dfc98b49b13
+ languageName: node
+ linkType: hard
+
"resize-observer-polyfill@npm:^1.5.1":
version: 1.5.1
resolution: "resize-observer-polyfill@npm:1.5.1"
@@ -44332,6 +44460,13 @@ __metadata:
languageName: node
linkType: hard
+"tiny-invariant@npm:^1.3.3":
+ version: 1.3.3
+ resolution: "tiny-invariant@npm:1.3.3"
+ checksum: 5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe
+ languageName: node
+ linkType: hard
+
"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3":
version: 1.0.3
resolution: "tiny-warning@npm:1.0.3"
@@ -46181,6 +46316,15 @@ __metadata:
languageName: node
linkType: hard
+"use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
+ version: 1.5.0
+ resolution: "use-sync-external-store@npm:1.5.0"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 5e639c9273200adb6985b512c96a3a02c458bc8ca1a72e91da9cdc6426144fc6538dca434b0f99b28fb1baabc82e1c383ba7900b25ccdcb43758fb058dc66c34
+ languageName: node
+ linkType: hard
+
"utif@npm:^2.0.1":
version: 2.0.1
resolution: "utif@npm:2.0.1"
@@ -46357,6 +46501,28 @@ __metadata:
languageName: node
linkType: hard
+"victory-vendor@npm:^37.0.2":
+ version: 37.3.6
+ resolution: "victory-vendor@npm:37.3.6"
+ dependencies:
+ "@types/d3-array": ^3.0.3
+ "@types/d3-ease": ^3.0.0
+ "@types/d3-interpolate": ^3.0.1
+ "@types/d3-scale": ^4.0.2
+ "@types/d3-shape": ^3.1.0
+ "@types/d3-time": ^3.0.0
+ "@types/d3-timer": ^3.0.0
+ d3-array: ^3.1.6
+ d3-ease: ^3.0.1
+ d3-interpolate: ^3.0.1
+ d3-scale: ^4.0.2
+ d3-shape: ^3.1.0
+ d3-time: ^3.0.0
+ d3-timer: ^3.0.1
+ checksum: fc49f195823f59466ac66d10266e5af783777a1da2f84aad7d3023ce2db149fd1522f1ecbd6d3223849ec1f4c33ba2e8fd9a5eb0443f076698c5832ed574731a
+ languageName: node
+ linkType: hard
+
"vite-node@npm:2.1.1":
version: 2.1.1
resolution: "vite-node@npm:2.1.1"