feat: add drop off funnel chart for /insights/routing (#22106)

* feat: add Drop-off funnel chart for /insights/routing

* convert insights services to use kysely

* WIP

* implement drop-off data WIP

* updates

* revert services back to Prisma

* do not expose findMany

* WIP

* revert usage

* fix type errors

* updates

* clean up

* update playground

* update styles

* support columnFilters

* update tooltip

* add skeleton

* remove dynamic loading

* avoid using Prisma.raw

* update condition for user scope

* Update packages/features/insights/components/RoutingFunnelContent.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* playground fix

* fix type error

* update playground

* update styles

* implement subtitle to ChartCard

* revert insights booking service

* update border

* update ChartCard style

* style updates

* convert the playground to a client component

* clean up

* apply feedback

* fix tests

* fix unit test

* hide scrollbar

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-07-16 16:36:41 +02:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent c2fc8774df
commit 10fca86ddc
24 changed files with 2193 additions and 247 deletions
@@ -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
) : (
<div>
<Link href="/settings/admin/playground" className="text-sm underline">
← Playground
</Link>
<div className="h-8" />
<div>{children}</div>
</div>
);
}
@@ -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 (
<div>
<h1 className="text-3xl font-bold">Playground</h1>
<ul className="mt-8">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title} →
</Link>
</li>
))}
</ul>
</div>
);
}
@@ -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 (
<div className="space-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
<p className="mt-2 text-gray-600">
This page demonstrates the RoutingFunnelContent component with sample data.
</p>
</div>
<div className="max-w-4xl">
<ChartCard
title={t("routing_funnel")}
subtitle="Hello world!"
legend={legend}
legendSize="sm"
cta={{
label: "Show all",
onClick: () => {
alert("hello!");
},
}}>
<RoutingFunnelContent data={sampleRoutingFunnelData} />
</ChartCard>
</div>
<div className="mt-8 rounded-lg bg-gray-50 p-4">
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
<pre className="overflow-auto text-sm text-gray-700">
{JSON.stringify(sampleRoutingFunnelData, null, 2)}
</pre>
</div>
</div>
);
}
@@ -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" },
],
},
];
@@ -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() {
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
<RoutingFunnel />
<FailedBookingsByField />
<div className="flex flex-col gap-4 md:flex-row">
<RoutedToPerPeriod />
<FailedBookingsByField />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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(${[]})`);
});
});
});
@@ -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;
}
@@ -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<LegendItem>;
legendSize?: LegendSize;
children: React.ReactNode;
}) {
return (
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
{cta && (
<Button className="shrink-0" color="secondary" onClick={cta.onClick}>
{cta.label}
</Button>
)}
</div>
</div>
<div className="bg-default border-muted w-full grow gap-3 rounded-xl border">
{subtitle && (
<h3 className="text-subtle border-muted border-b p-3 text-sm font-medium leading-none">
{subtitle}
</h3>
)}
{children}
</div>
</div>
);
}
function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) {
return (
<div className="bg-default flex items-center gap-2 rounded-lg px-1.5 py-1">
{items.map((item, index) => (
<Fragment key={item.label}>
<div
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
style={{ backgroundColor: `${item.color}33` }}>
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<Tooltip content={item.label}>
<p
className={classNames(
"text-default truncate text-sm font-medium leading-none",
size === "sm" ? "w-16" : ""
)}>
{item.label}
</p>
</Tooltip>
</div>
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
</Fragment>
))}
</div>
);
}
@@ -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 (
<div className="border-subtle w-full rounded-md border">
<div className="flex flex-col">
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
</div>
<div className="p-4">
<ToggleGroup
options={toggleOptions}
value={selectedField}
className="w-fit"
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
className="mt-2"
/>
</div>
)}
</div>
<div className="flex flex-col">
<div className="p-4">
<ToggleGroup
options={toggleOptions}
value={selectedField}
className="w-fit"
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
className="mt-2"
/>
</div>
)}
</div>
</div>
);
}
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 (
<div className="w-full text-sm">
<div className="flex h-12 items-center">
<h2 className="text-emphasis text-md font-semibold">Failed Bookings By Field</h2>
</div>
<div className="">
<div className={classNames("grid grid-cols-1 gap-6 lg:grid-cols-2")}>
{Object.entries(data).map(([formName, fields]) => (
<FormCard key={formName} formName={formName} fields={fields} />
))}
</div>
</div>
</div>
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
<FormCard key={formName} formName={formName} fields={fields} />
</ChartCard>
);
}
@@ -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 (
<div className="w-full text-sm">
<div className="flex h-12 items-center">
<h2 className="text-emphasis text-md font-semibold">{t("routed_to_per_period")}</h2>
</div>
<ChartCard title={t("routed_to_per_period")}>
<div className="w-full text-sm">
<FormCard
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={routingFormId}
startDate={startDate}
endDate={endDate}>
<div className="mt-6">
<div
className="scrollbar-thin border-subtle relative overflow-auto rounded-md border"
ref={tableContainerRef}>
<TableNew className="border-0">
<TableHeader className="bg-subtle sticky top-0 z-10">
<TableRow>
<TableHead className="bg-subtle sticky left-0 z-30 w-[200px]">{t("user")}</TableHead>
{uniquePeriods.map((period, index) => {
const date = period;
const today = new Date();
<FormCard
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={routingFormId}
startDate={startDate}
endDate={endDate}>
<div className="mt-6">
<div
className="scrollbar-thin border-subtle relaitve relative h-[80dvh] overflow-auto rounded-md border"
ref={tableContainerRef}>
<TableNew className="border-0">
<TableHeader className="bg-subtle sticky top-0 z-10">
<TableRow>
<TableHead className="bg-subtle sticky left-0 z-30 w-[200px]">{t("user")}</TableHead>
{uniquePeriods.map((period, index) => {
const date = period;
const today = new Date();
const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
return (
<TableHead
key={period.toISOString()}
className="text-center"
data-is-current={isCurrent}>
<span className={classNames(isCurrent && "font-bold")}>
{date.toLocaleDateString()}
</span>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="relative">
{processedData.map((row, index) => {
return (
<TableHead
key={period.toISOString()}
className="text-center"
data-is-current={isCurrent}>
<span className={classNames(isCurrent && "font-bold")}>
{date.toLocaleDateString()}
</span>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="relative">
{processedData.map((row, index) => {
return (
<TableRow
key={row.id}
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
className="divide-muted divide-x">
<TableCell className="bg-default w-[200px]">
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex cursor-pointer items-center gap-2">
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
<div className="flex flex-col gap-1 truncate">
<span>{row.name}</span>
</div>
</div>
</HoverCardTrigger>
<HoverCardContent className="p-3">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-default font-medium">{row.name}</span>
</div>
<div className="text-subtle flex flex-col text-sm">
<div>
{t("total_bookings_per_period")}: <Badge>{row.totalBookings}</Badge>
{t("status")}: {getPerformanceBadge(row.performance, t)}
<TableRow
key={row.id}
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
className="divide-muted divide-x">
<TableCell className="bg-default w-[200px]">
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex cursor-pointer items-center gap-2">
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
<div className="flex flex-col gap-1 truncate">
<span>{row.name}</span>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</TableCell>
{uniquePeriods.map((period) => {
return (
<TableCell key={period.toISOString()} className="text-center">
{row.stats[period.toISOString()] === 0 ? null : (
<>{row.stats[period.toISOString()]}</>
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</TableNew>
</HoverCardTrigger>
<HoverCardContent className="p-3">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-default font-medium">{row.name}</span>
</div>
<div className="text-subtle flex flex-col text-sm">
<div>
{t("total_bookings_per_period")}: <Badge>{row.totalBookings}</Badge>
{t("status")}: {getPerformanceBadge(row.performance, t)}
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</TableCell>
{uniquePeriods.map((period) => {
return (
<TableCell key={period.toISOString()} className="text-center">
{row.stats[period.toISOString()] === 0 ? null : (
<>{row.stats[period.toISOString()]}</>
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</TableNew>
</div>
</div>
</div>
</FormCard>
</div>
</FormCard>
</div>
</ChartCard>
);
}
@@ -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 (
<ChartCard title={t("routing_funnel")} legend={legend}>
<RoutingFunnelSkeleton />
</ChartCard>
);
}
return (
<ChartCard title={t("routing_funnel")} legend={legend}>
<RoutingFunnelContent data={data} />
</ChartCard>
);
}
@@ -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 (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" className="text-xs" axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
<Tooltip content={<CustomTooltip />} />
<Area
type="linear"
name={t("routing_funnel_total_submissions")}
dataKey="totalSubmissions"
stroke={COLOR.TOTAL}
fill={COLOR.TOTAL}
fillOpacity={1}
/>
<Area
type="linear"
name={t("routing_funnel_successful_routings")}
dataKey="successfulRoutings"
stroke={COLOR.SUCCESFUL}
fill={COLOR.SUCCESFUL}
fillOpacity={1}
/>
<Area
type="linear"
name={t("routing_funnel_accepted_bookings")}
dataKey="acceptedBookings"
stroke={COLOR.ACCEPTED}
fill={COLOR.ACCEPTED}
fillOpacity={1}
/>
</AreaChart>
</ResponsiveContainer>
);
}
// 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 (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
{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 (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {displayValue}
</p>
);
})}
</div>
);
};
@@ -0,0 +1,76 @@
"use client";
import { SkeletonText } from "@calcom/ui/components/skeleton";
export function RoutingFunnelSkeleton() {
return (
<div className="relative h-[300px] w-full">
{/* Chart container */}
<div className="h-full w-full p-4">
{/* Chart area */}
<div className="relative h-[220px]">
{/* Y-axis */}
<div className="text-muted-foreground absolute bottom-0 left-0 top-0 flex w-8 flex-col justify-between text-xs">
<SkeletonText className="h-3 w-6" />
<SkeletonText className="h-3 w-6" />
<SkeletonText className="h-3 w-6" />
<SkeletonText className="h-3 w-6" />
<SkeletonText className="h-3 w-6" />
</div>
{/* Chart content */}
<div className="ml-8 h-full">
{/* Grid lines */}
<div className="flex h-full flex-col justify-between">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="border-muted border-b" />
))}
</div>
{/* Stacked area chart skeleton */}
<div className="absolute inset-0 bottom-0 top-0 ml-0 flex items-end justify-between px-4">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex w-8 flex-col items-center">
{/* Stacked areas - simulating the three data series */}
<div
className="w-full rounded-t bg-[#8884d8] opacity-20"
style={{ height: `${Math.random() * 40 + 30}%` }}
/>
<div
className="w-full bg-[#83a6ed] opacity-20"
style={{ height: `${Math.random() * 30 + 20}%` }}
/>
<div
className="w-full rounded-b bg-[#82ca9d] opacity-20"
style={{ height: `${Math.random() * 20 + 10}%` }}
/>
{/* X-axis label */}
<SkeletonText className="mt-2 h-3 w-8" />
</div>
))}
</div>
</div>
</div>
{/* Legend area */}
<div className="mt-4 flex justify-center">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-sm bg-[#8884d8]" />
<SkeletonText className="h-3 w-24" />
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-sm bg-[#83a6ed]" />
<SkeletonText className="h-3 w-28" />
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-sm bg-[#82ca9d]" />
<SkeletonText className="w-26 h-3" />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -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";
@@ -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,
};
}
@@ -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,
+1 -1
View File
@@ -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 {
@@ -61,3 +61,11 @@ export const routingFormResponsesInputSchema = z.object({
offset: z.number().optional(),
limit: z.number().max(100).optional(),
}) satisfies z.ZodType<RoutingFormResponsesInput>;
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(),
});
@@ -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({
+1
View File
@@ -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"
@@ -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<Array<{ id: number }>>`
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<Array<{ id: number }>>`
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<Array<{ id: number }>>`
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();
});
});
});
+315 -52
View File
@@ -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<typeof insightsRoutingServiceOptionsSchema>;
export type InsightsRoutingServiceFilterOptions = {
// Empty for now
startDate?: string;
endDate?: string;
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
};
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<Prisma.RoutingFormResponseDenormalizedWhereInput> {
async getBaseConditions(): Promise<Prisma.Sql> {
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<Prisma.Sql> {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
async getFilterConditions(): Promise<Prisma.Sql | null> {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
// Empty for now
return null;
async buildFilterConditions(): Promise<Prisma.Sql | null> {
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<string, TypedColumnFilter<ColumnFilterType>>) || {};
// 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<Prisma.RoutingFormResponseDenormalizedWhereInput> {
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
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<InsightsRoutingServiceOptions, { scope: "org" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
): Promise<Prisma.Sql> {
// 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<InsightsRoutingServiceOptions, { scope: "team" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
): Promise<Prisma.Sql> {
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<boolean> {
@@ -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})
)`;
}
}
+167 -1
View File
@@ -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"