diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/layout.tsx new file mode 100644 index 0000000000..b2c151e14b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function PlaygroundLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + const isPlaygroundRoot = pathname === "/settings/admin/playground"; + + return isPlaygroundRoot ? ( + children + ) : ( +
+ + ← Playground + +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx new file mode 100644 index 0000000000..bb5f742915 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +const LINKS = [ + { + title: "Routing Funnel", + href: "/settings/admin/playground/routing-funnel", + }, +]; + +export default function Page() { + return ( +
+

Playground

+ + +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx new file mode 100644 index 0000000000..3f3c61bbd8 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { ChartCard } from "@calcom/features/insights/components/ChartCard"; +import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +// Random sample data for playground testing +const sampleRoutingFunnelData = [ + { + name: "Week 1", + formattedDateFull: "Week 1", + totalSubmissions: 150, + successfulRoutings: 120, + acceptedBookings: 95, + }, + { + name: "Week 2", + formattedDateFull: "Week 2", + totalSubmissions: 180, + successfulRoutings: 145, + acceptedBookings: 110, + }, + { + name: "Week 3", + formattedDateFull: "Week 3", + totalSubmissions: 200, + successfulRoutings: 160, + acceptedBookings: 125, + }, + { + name: "Week 4", + formattedDateFull: "Week 4", + totalSubmissions: 170, + successfulRoutings: 135, + acceptedBookings: 105, + }, + { + name: "Week 5", + formattedDateFull: "Week 5", + totalSubmissions: 220, + successfulRoutings: 175, + acceptedBookings: 140, + }, + { + name: "Week 6", + formattedDateFull: "Week 6", + totalSubmissions: 190, + successfulRoutings: 155, + acceptedBookings: 120, + }, +]; + +export default function RoutingFunnelPlayground() { + const { t } = useLocale(); + return ( +
+
+

Routing Funnel Playground

+

+ This page demonstrates the RoutingFunnelContent component with sample data. +

+
+ +
+ { + alert("hello!"); + }, + }}> + + +
+ +
+

Sample Data Used:

+
+          {JSON.stringify(sampleRoutingFunnelData, null, 2)}
+        
+
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index f28f04e81d..6d847ca60b 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -147,6 +147,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { { name: "lockedSMS", href: "/settings/admin/lockedSMS" }, { name: "oAuth", href: "/settings/admin/oAuth" }, { name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" }, + { name: "Playground", href: "/settings/admin/playground" }, ], }, ]; diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index c11b6198fb..e6501bc25c 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -6,6 +6,7 @@ import { RoutingFormResponsesTable, FailedBookingsByField, RoutedToPerPeriod, + RoutingFunnel, } from "@calcom/features/insights/components"; import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -19,9 +20,12 @@ export default function InsightsRoutingFormResponsesPage() {
- + - +
+ + +
{t("looking_for_more_insights")}{" "} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 52c8f34730..16fe6157b7 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -76,7 +76,7 @@ "you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar", "no_options_available": "No options available", "cancellation_reason": "Reason for cancellation (optional)", - "cancelled_by":"Cancelled By", + "cancelled_by": "Cancelled By", "cancellation_reason_host": "Reason for cancellation", "cancellation_reason_placeholder": "Why are you cancelling?", "notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests", @@ -285,7 +285,7 @@ "guests": "Guests", "guest": "Guest", "web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.", - "confirmation":"Confirmation", + "confirmation": "Confirmation", "what_booker_should_provide": "What your booker should provide to receive confirmations", "404_the_user": "The username", "username": "Username", @@ -3349,6 +3349,10 @@ "booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.", "restriction_schedule_not_found_error": "Restriction schedule not found", "converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format", + "routing_funnel": "Routing Funnel", + "routing_funnel_total_submissions": "Total Submissions", + "routing_funnel_successful_routings": "Successful Routings", + "routing_funnel_accepted_bookings": "Accepted Bookings", "license_key": "License key", "license_selection_title": "Choose your license", "license_selection_description": "Select how you want to license your Cal.com deployment", @@ -3367,6 +3371,7 @@ "license_validation_failed": "Failed to validate license key", "license_key_saved": "License key saved successfully", "timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})", + "failed_bookings_by_field": "Failed Bookings By Field", "event_type_no_hosts": "No hosts are assigned to event type", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/data-table/lib/__tests__/server.test.ts b/packages/features/data-table/lib/__tests__/server.test.ts new file mode 100644 index 0000000000..c0b8ab4561 --- /dev/null +++ b/packages/features/data-table/lib/__tests__/server.test.ts @@ -0,0 +1,265 @@ +import { Prisma } from "@prisma/client"; +import { describe, expect, it } from "vitest"; + +import { makeSqlCondition } from "../server"; +import { ColumnFilterType } from "../types"; +import type { + MultiSelectFilterValue, + SingleSelectFilterValue, + TextFilterValue, + NumberFilterValue, +} from "../types"; + +describe("makeSqlCondition", () => { + describe("Multi-select filter values", () => { + it("should create ANY condition for multi-select values", () => { + const filterValue: MultiSelectFilterValue = { + type: ColumnFilterType.MULTI_SELECT, + data: ["option1", "option2", "option3"], + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ANY(${["option1", "option2", "option3"]})`); + }); + + it("should work with table alias", () => { + const filterValue: MultiSelectFilterValue = { + type: ColumnFilterType.MULTI_SELECT, + data: [1, 2, 3], + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ANY(${[1, 2, 3]})`); + }); + }); + + describe("Single-select filter values", () => { + it("should create equals condition for single-select values", () => { + const filterValue: SingleSelectFilterValue = { + type: ColumnFilterType.SINGLE_SELECT, + data: "option1", + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ${"option1"}`); + }); + + it("should work with numeric values", () => { + const filterValue: SingleSelectFilterValue = { + type: ColumnFilterType.SINGLE_SELECT, + data: 123, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ${123}`); + }); + }); + + describe("Text filter values", () => { + it("should create equals condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "equals", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ${"test"}`); + }); + + it("should create not equals condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "notEquals", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`!= ${"test"}`); + }); + + it("should create contains condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "contains", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`ILIKE ${`%test%`}`); + }); + + it("should create not contains condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "notContains", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`NOT ILIKE ${`%test%`}`); + }); + + it("should create starts with condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "startsWith", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`ILIKE ${`test%`}`); + }); + + it("should create ends with condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "endsWith", operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`ILIKE ${`%test`}`); + }); + + it("should create is empty condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "isEmpty", operand: "" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ''`); + }); + + it("should create is not empty condition", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "isNotEmpty", operand: "" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`!= ''`); + }); + + it("should return null for unknown text operator", () => { + const filterValue: TextFilterValue = { + type: ColumnFilterType.TEXT, + data: { operator: "unknown" as any, operand: "test" }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toBeNull(); + }); + }); + + describe("Number filter values", () => { + it("should create equals condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "eq", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ${42}`); + }); + + it("should create not equals condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "neq", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`!= ${42}`); + }); + + it("should create greater than condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "gt", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`> ${42}`); + }); + + it("should create greater than or equal condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "gte", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`>= ${42}`); + }); + + it("should create less than condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "lt", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`< ${42}`); + }); + + it("should create less than or equal condition", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "lte", operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`<= ${42}`); + }); + + it("should return null for unknown number operator", () => { + const filterValue: NumberFilterValue = { + type: ColumnFilterType.NUMBER, + data: { operator: "unknown" as any, operand: 42 }, + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toBeNull(); + }); + }); + + describe("Edge cases", () => { + it("should return null for unknown filter type", () => { + const filterValue = { + type: "unknown" as any, + data: "test", + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toBeNull(); + }); + + it("should handle empty multi-select array", () => { + const filterValue: MultiSelectFilterValue = { + type: ColumnFilterType.MULTI_SELECT, + data: [], + }; + + const result = makeSqlCondition(filterValue); + + expect(result).toEqual(Prisma.sql`= ANY(${[]})`); + }); + }); +}); diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index 1547653f5b..ced6f79931 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -1,3 +1,5 @@ +import { Prisma } from "@prisma/client"; + import type { FilterValue, SortingState } from "./types"; import { isSingleSelectFilterValue, @@ -21,6 +23,9 @@ export function makeOrderBy(sorting: SortingState) { })); } +/** + * Builds a Prisma where clause for use with Prisma queries + */ export function makeWhereClause(props: MakeWhereClauseProps) { const { columnName, filterValue } = props; const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0); @@ -171,3 +176,56 @@ export function makeWhereClause(props: MakeWhereClauseProps) { } throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`); } + +/** + * Builds a SQL where clause for use with raw SQL queries + */ +export function makeSqlCondition(filterValue: FilterValue): Prisma.Sql | null { + if (isMultiSelectFilterValue(filterValue)) { + return Prisma.sql`= ANY(${filterValue.data})`; + } else if (isSingleSelectFilterValue(filterValue)) { + return Prisma.sql`= ${filterValue.data}`; + } else if (isTextFilterValue(filterValue)) { + const { operator, operand } = filterValue.data; + switch (operator) { + case "equals": + return Prisma.sql`= ${operand}`; + case "notEquals": + return Prisma.sql`!= ${operand}`; + case "contains": + return Prisma.sql`ILIKE ${`%${operand}%`}`; + case "notContains": + return Prisma.sql`NOT ILIKE ${`%${operand}%`}`; + case "startsWith": + return Prisma.sql`ILIKE ${`${operand}%`}`; + case "endsWith": + return Prisma.sql`ILIKE ${`%${operand}`}`; + case "isEmpty": + return Prisma.sql`= ''`; + case "isNotEmpty": + return Prisma.sql`!= ''`; + default: + return null; + } + } else if (isNumberFilterValue(filterValue)) { + const { operator, operand } = filterValue.data; + switch (operator) { + case "eq": + return Prisma.sql`= ${operand}`; + case "neq": + return Prisma.sql`!= ${operand}`; + case "gt": + return Prisma.sql`> ${operand}`; + case "gte": + return Prisma.sql`>= ${operand}`; + case "lt": + return Prisma.sql`< ${operand}`; + case "lte": + return Prisma.sql`<= ${operand}`; + default: + return null; + } + } + + return null; +} diff --git a/packages/features/insights/components/ChartCard.tsx b/packages/features/insights/components/ChartCard.tsx new file mode 100644 index 0000000000..dcdc965792 --- /dev/null +++ b/packages/features/insights/components/ChartCard.tsx @@ -0,0 +1,78 @@ +import { Fragment } from "react"; + +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +type LegendItem = { + label: string; + color: string; // hex format +}; + +export type LegendSize = "sm" | "default"; + +export function ChartCard({ + title, + subtitle, + cta, + legend, + legendSize, + children, +}: { + title: string; + subtitle?: string; + cta?: { label: string; onClick: () => void }; + legend?: Array; + legendSize?: LegendSize; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+
+ {legend && legend.length > 0 && } + {cta && ( + + )} +
+
+
+ {subtitle && ( +

+ {subtitle} +

+ )} + {children} +
+
+ ); +} + +function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) { + return ( +
+ {items.map((item, index) => ( + +
+
+ +

+ {item.label} +

+
+
+ {index < items.length - 1 &&
} + + ))} +
+ ); +} diff --git a/packages/features/insights/components/FailedBookingsByField.tsx b/packages/features/insights/components/FailedBookingsByField.tsx index da774bc616..27fd463775 100644 --- a/packages/features/insights/components/FailedBookingsByField.tsx +++ b/packages/features/insights/components/FailedBookingsByField.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { ToggleGroup } from "@calcom/ui/components/form"; -import classNames from "@calcom/ui/classNames"; import { useInsightsParameters } from "../hooks/useInsightsParameters"; +import { ChartCard } from "./ChartCard"; import { BarList } from "./tremor/BarList"; interface FormCardProps { @@ -27,34 +28,30 @@ function FormCard({ formName, fields }: FormCardProps) { })); return ( -
-
-
-

{formName}

-
-
- value && setSelectedField(value)} - /> - {selectedFieldData && ( -
- value.toString()} - className="mt-2" - /> -
- )} -
+
+
+ value && setSelectedField(value)} + /> + {selectedFieldData && ( +
+ value.toString()} + className="mt-2" + /> +
+ )}
); } export function FailedBookingsByField() { + const { t } = useLocale(); const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters(); const { data } = trpc.viewer.insights.failedBookingsByField.useQuery({ userId, @@ -63,20 +60,13 @@ export function FailedBookingsByField() { routingFormId, }); - if (!data) return null; + if (!data || Object.entries(data).length === 0) return null; + // routingFormId is always set, meaning data has only one entry. + const [formName, fields] = Object.entries(data)[0]; return ( -
-
-

Failed Bookings By Field

-
-
-
- {Object.entries(data).map(([formName, fields]) => ( - - ))} -
-
-
+ + + ); } diff --git a/packages/features/insights/components/RoutedToPerPeriod.tsx b/packages/features/insights/components/RoutedToPerPeriod.tsx index 002506d7ef..793e50c3dd 100644 --- a/packages/features/insights/components/RoutedToPerPeriod.tsx +++ b/packages/features/insights/components/RoutedToPerPeriod.tsx @@ -24,6 +24,7 @@ import { import { Tooltip } from "@calcom/ui/components/tooltip"; import { useInsightsParameters } from "../hooks/useInsightsParameters"; +import { ChartCard } from "./ChartCard"; interface DownloadButtonProps { teamId?: number; @@ -365,98 +366,96 @@ export function RoutedToPerPeriod() { }; return ( -
-
-

{t("routed_to_per_period")}

-
+ +
+ +
+
+ + + + {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} -
-
-
- -
-
- {row.name} -
-
-
- {t("total_bookings_per_period")}: {row.totalBookings} - {t("status")}: {getPerformanceBadge(row.performance, t)} + + + + +
+ +
+ {row.name}
-
- - - - {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"