From a67b57b216c67e31f8466ad9a6bd273eacc25047 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 3 Jan 2025 11:58:01 +0100 Subject: [PATCH] feat: server side sorting for insights routing (#18113) * feat: server side sorting for insights routing ^ Conflicts: ^ packages/prisma/playground.ts * fix download csv * fix hooks locations --- .../data-table/components/DataTable.tsx | 8 +- .../components/DataTableToolbar.tsx | 2 +- .../filters/MultiSelectFilterOptions.tsx | 2 +- .../filters/NumberFilterOptions.tsx | 2 +- .../filters/SingleSelectFilterOptions.tsx | 2 +- .../components/filters/TextFilterOptions.tsx | 2 +- .../data-table/components/filters/index.tsx | 8 +- packages/features/data-table/hooks/index.ts | 3 + .../data-table/hooks/useColumnFilters.ts | 35 ++ .../features/data-table/hooks/useDataTable.ts | 11 + .../data-table/hooks/useFilterValue.ts | 18 + packages/features/data-table/index.ts | 1 + packages/features/data-table/lib/context.tsx | 18 +- packages/features/data-table/lib/server.ts | 10 +- packages/features/data-table/lib/types.ts | 9 +- packages/features/data-table/lib/utils.ts | 85 ++--- .../components/RoutingFormResponsesTable.tsx | 314 +++++++++--------- .../filters/Download/RoutingDownload.tsx | 2 +- .../Download/RoutingFormResponsesDownload.tsx | 115 +++++++ .../insights/filters/Download/index.tsx | 1 + packages/features/insights/lib/types.ts | 32 ++ .../insights/server/routing-events.ts | 296 ++++++++++++----- .../features/insights/server/trpc-router.ts | 30 +- .../migration.sql | 72 ++++ packages/prisma/schema.prisma | 24 ++ packages/prisma/seed-insights.ts | 33 +- .../organizations/listMembers.handler.ts | 6 +- 27 files changed, 797 insertions(+), 344 deletions(-) create mode 100644 packages/features/data-table/hooks/useColumnFilters.ts create mode 100644 packages/features/data-table/hooks/useDataTable.ts create mode 100644 packages/features/data-table/hooks/useFilterValue.ts create mode 100644 packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx create mode 100644 packages/features/insights/lib/types.ts create mode 100644 packages/prisma/migrations/20241212164338_create_routing_form_response/migration.sql diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index cc6215208f..6469716230 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -75,7 +75,7 @@ export function DataTable({ const columnSizingVars = useColumnSizingVars({ table }); usePersistentColumnResizing({ - enabled: Boolean(enableColumnResizing), + enabled: Boolean(enableColumnResizing && identifier), table, tableContainerRef, identifier, @@ -118,8 +118,10 @@ export function DataTable({ width: `var(--header-${kebabCase(header?.id)}-size)`, }} className={classNames( - "bg-subtle hover:bg-muted relative flex shrink-0 items-center", - header.column.getCanSort() ? "cursor-pointer select-none" : "", + "relative flex shrink-0 items-center", + header.column.getCanSort() + ? "bg-subtle hover:bg-muted cursor-pointer select-none" + : "", meta?.sticky && "top-0 z-20 sm:sticky" )}>
{ children: React.ReactNode; diff --git a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx index 2a1d37ea96..eeeb17a058 100644 --- a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx @@ -14,9 +14,9 @@ import { Icon, } from "@calcom/ui"; +import { useDataTable, useFilterValue } from "../../hooks"; import type { FilterableColumn } from "../../lib/types"; import { ZMultiSelectFilterValue } from "../../lib/types"; -import { useDataTable, useFilterValue } from "../../lib/utils"; export type MultiSelectFilterOptionsProps = { column: Extract; diff --git a/packages/features/data-table/components/filters/NumberFilterOptions.tsx b/packages/features/data-table/components/filters/NumberFilterOptions.tsx index fdba21024e..1d7e24b22b 100644 --- a/packages/features/data-table/components/filters/NumberFilterOptions.tsx +++ b/packages/features/data-table/components/filters/NumberFilterOptions.tsx @@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Form, Input, Select, Button } from "@calcom/ui"; +import { useFilterValue, useDataTable } from "../../hooks"; import type { FilterableColumn, NumberFilterOperator } from "../../lib/types"; import { ZNumberFilterValue } from "../../lib/types"; -import { useFilterValue, useDataTable } from "../../lib/utils"; export type NumberFilterOperatorOption = { label: string; diff --git a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx index 07f9eb24da..6d1742daea 100644 --- a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx @@ -14,9 +14,9 @@ import { Icon, } from "@calcom/ui"; +import { useDataTable, useFilterValue } from "../../hooks"; import type { FilterableColumn } from "../../lib/types"; import { ZSingleSelectFilterValue } from "../../lib/types"; -import { useDataTable, useFilterValue } from "../../lib/utils"; export type SingleSelectFilterOptionsProps = { column: Extract; diff --git a/packages/features/data-table/components/filters/TextFilterOptions.tsx b/packages/features/data-table/components/filters/TextFilterOptions.tsx index 0a3394a23d..439eb5d47c 100644 --- a/packages/features/data-table/components/filters/TextFilterOptions.tsx +++ b/packages/features/data-table/components/filters/TextFilterOptions.tsx @@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Form, Input, Select, Button } from "@calcom/ui"; +import { useFilterValue, useDataTable } from "../../hooks"; import type { FilterableColumn, TextFilterOperator } from "../../lib/types"; import { ZTextFilterValue } from "../../lib/types"; -import { useFilterValue, useDataTable } from "../../lib/utils"; export type TextFilterOperatorOption = { label: string; diff --git a/packages/features/data-table/components/filters/index.tsx b/packages/features/data-table/components/filters/index.tsx index 6d252effe3..65f37e2953 100644 --- a/packages/features/data-table/components/filters/index.tsx +++ b/packages/features/data-table/components/filters/index.tsx @@ -1,6 +1,8 @@ "use client"; import { type Table } from "@tanstack/react-table"; +// eslint-disable-next-line no-restricted-imports +import startCase from "lodash/startCase"; import { forwardRef, useState, useMemo, useCallback, Fragment } from "react"; import { classNames } from "@calcom/lib"; @@ -22,8 +24,8 @@ import { Icon, } from "@calcom/ui"; +import { useDataTable } from "../../hooks"; import type { FilterableColumn, ExternalFilter } from "../../lib/types"; -import { convertToTitleCase, useDataTable } from "../../lib/utils"; import { FilterOptions } from "./FilterOptions"; interface ColumnVisiblityProps { @@ -164,7 +166,7 @@ function AddFilterButtonComponent( key={column.id} onSelect={() => handleAddFilter(column.id)} className="px-4 py-2"> - {convertToTitleCase(column.title)} + {startCase(column.title)} ); })} @@ -257,7 +259,7 @@ function ActiveFilters({ table, externalFilters }: ActiveFiltersProps diff --git a/packages/features/data-table/hooks/index.ts b/packages/features/data-table/hooks/index.ts index b1350185c2..d504af94ee 100644 --- a/packages/features/data-table/hooks/index.ts +++ b/packages/features/data-table/hooks/index.ts @@ -1,3 +1,6 @@ export * from "./useDebouncedWidth"; export * from "./useFetchMoreOnBottomReached"; export * from "./useColumnSizingVars"; +export * from "./useFilterValue"; +export * from "./useColumnFilters"; +export * from "./useDataTable"; diff --git a/packages/features/data-table/hooks/useColumnFilters.ts b/packages/features/data-table/hooks/useColumnFilters.ts new file mode 100644 index 0000000000..31dca6344f --- /dev/null +++ b/packages/features/data-table/hooks/useColumnFilters.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import type { ColumnFilter } from "../lib/types"; +import { ZFilterValue } from "../lib/types"; +import { isMultiSelectFilterValue } from "../lib/utils"; +import { useDataTable } from "./useDataTable"; + +export function useColumnFilters(): ColumnFilter[] { + const { activeFilters } = useDataTable(); + return useMemo(() => { + return (activeFilters || []) + .filter( + (filter) => + typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined + ) + .map((filter) => { + const parsedValue = ZFilterValue.safeParse(filter.v); + if (!parsedValue.success) return null; + return { + id: filter.f, + value: parsedValue.data, + }; + }) + .filter((filter): filter is ColumnFilter => filter !== null) + .filter((filter) => { + // The empty arrays in `filtersSearchState` keep the filter UI component, + // but we do not send them to the actual query. + // Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned. + if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) { + return false; + } + return true; + }); + }, [activeFilters]); +} diff --git a/packages/features/data-table/hooks/useDataTable.ts b/packages/features/data-table/hooks/useDataTable.ts new file mode 100644 index 0000000000..c62ecfa9ee --- /dev/null +++ b/packages/features/data-table/hooks/useDataTable.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; + +import { DataTableContext } from "../lib/context"; + +export function useDataTable() { + const context = useContext(DataTableContext); + if (!context) { + throw new Error("useDataTable must be used within a DataTableProvider"); + } + return context; +} diff --git a/packages/features/data-table/hooks/useFilterValue.ts b/packages/features/data-table/hooks/useFilterValue.ts new file mode 100644 index 0000000000..6e3444365e --- /dev/null +++ b/packages/features/data-table/hooks/useFilterValue.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import type { z } from "zod"; + +import { useDataTable } from "./useDataTable"; + +export function useFilterValue(columnId: string, schema: z.ZodType) { + const { activeFilters } = useDataTable(); + return useMemo(() => { + const value = activeFilters.find((filter) => filter.f === columnId)?.v; + if (schema && value) { + const result = schema.safeParse(value); + if (result.success) { + return result.data; + } + } + return undefined; + }, [activeFilters, columnId, schema]); +} diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 474b7f614f..22eb20527b 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -2,4 +2,5 @@ export * from "./components"; export * from "./lib/types"; export * from "./lib/utils"; export * from "./lib/context"; +export * from "./lib/resizing"; export * from "./hooks"; diff --git a/packages/features/data-table/lib/context.tsx b/packages/features/data-table/lib/context.tsx index 68b47629c8..2f9c4fce68 100644 --- a/packages/features/data-table/lib/context.tsx +++ b/packages/features/data-table/lib/context.tsx @@ -1,17 +1,18 @@ "use client"; +import type { SortingState, OnChangeFn } from "@tanstack/react-table"; import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs"; import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react"; import { z } from "zod"; -import { type FilterValue, ZFilterValue } from "./types"; +import { type FilterValue, ZFilterValue, ZSorting } from "./types"; -const dataTableFiltersSchema = z.object({ +const ZActiveFilter = z.object({ f: z.string(), v: ZFilterValue.optional(), }); -type ActiveFilter = z.infer; +type ActiveFilter = z.infer; export type DataTableContextType = { activeFilters: ActiveFilter[]; @@ -20,6 +21,9 @@ export type DataTableContextType = { updateFilter: (columnId: string, value: FilterValue) => void; removeFilter: (columnId: string) => void; + sorting: SortingState; + setSorting: OnChangeFn; + displayedExternalFilters: string[]; setDisplayedExternalFilters: Dispatch>; removeDisplayedExternalFilter: (key: string) => void; @@ -30,7 +34,11 @@ export const DataTableContext = createContext(null) export function DataTableProvider({ children }: { children: React.ReactNode }) { const [activeFilters, setActiveFilters] = useQueryState( "activeFilters", - parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([]) + parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault([]) + ); + const [sorting, setSorting] = useQueryState( + "sorting", + parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault([]) ); const [displayedExternalFilters, setDisplayedExternalFilters] = useState([]); @@ -70,6 +78,8 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) { clearAll, updateFilter, removeFilter, + sorting, + setSorting, displayedExternalFilters, setDisplayedExternalFilters, removeDisplayedExternalFilter, diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index f15f17bb26..4bee912079 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -1,4 +1,4 @@ -import type { FilterValue } from "./types"; +import type { FilterValue, SortingState } from "./types"; import { isSingleSelectFilterValue, isMultiSelectFilterValue, @@ -12,6 +12,14 @@ type makeWhereClauseProps = { json?: true | { path: string[] }; }; +export function makeOrderBy(sorting: SortingState) { + if (!sorting || !sorting.length) return undefined; + + return sorting.map((sort) => ({ + [sort.id]: sort.desc ? ("desc" as const) : ("asc" as const), + })); +} + export function makeWhereClause(props: makeWhereClauseProps) { const { columnName, filterValue } = props; const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0); diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 55770c60d7..11c8b844bb 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -24,7 +24,7 @@ export type SingleSelectFilterValue = z.infer; export const ZMultiSelectFilterValue = z.object({ type: z.literal("multi_select"), - data: z.array(z.string()), + data: z.union([z.string(), z.number()]).array(), }); export type MultiSelectFilterValue = z.infer; @@ -125,3 +125,10 @@ export type ExternalFilter = { titleKey: string; component: () => React.ReactNode; }; + +export const ZSorting = z.object({ + id: z.string(), + desc: z.boolean(), +}); + +export type SortingState = Array>; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index 984778834b..c9ef419745 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -1,78 +1,21 @@ "use client"; -import { useMemo, useContext } from "react"; -import type { z } from "zod"; - -import { DataTableContext } from "./context"; import type { SingleSelectFilterValue, MultiSelectFilterValue, TextFilterValue, FilterValue, NumberFilterValue, - ColumnFilter, } from "./types"; import { - ZFilterValue, ZNumberFilterValue, ZSingleSelectFilterValue, ZMultiSelectFilterValue, ZTextFilterValue, } from "./types"; -export function useDataTable() { - const context = useContext(DataTableContext); - if (!context) { - throw new Error("useDataTable must be used within a DataTableProvider"); - } - return context; -} - -export function useFilterValue(columnId: string, schema: z.ZodType) { - const { activeFilters } = useDataTable(); - return useMemo(() => { - const value = activeFilters.find((filter) => filter.f === columnId)?.v; - if (schema && value) { - const result = schema.safeParse(value); - if (result.success) { - return result.data; - } - } - return undefined; - }, [activeFilters, columnId, schema]); -} - -export function useColumnFilters(): ColumnFilter[] { - const { activeFilters } = useDataTable(); - return useMemo(() => { - return (activeFilters || []) - .filter( - (filter) => - typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined - ) - .map((filter) => { - const parsedValue = ZFilterValue.safeParse(filter.v); - if (!parsedValue.success) return null; - return { - id: filter.f, - value: parsedValue.data, - }; - }) - .filter((filter): filter is ColumnFilter => filter !== null) - .filter((filter) => { - // The empty arrays in `filtersSearchState` keep the filter UI component, - // but we do not send them to the actual query. - // Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned. - if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) { - return false; - } - return true; - }); - }, [activeFilters]); -} - export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => { - if (filterValue.data.operator === "isEmpty" && cellValue === undefined) { + if (filterValue.data.operator === "isEmpty" && !cellValue) { return true; } @@ -106,13 +49,29 @@ export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilt return ZTextFilterValue.safeParse(filterValue).success; }; +const isAllString = (array: (string | number)[]): array is string[] => { + return array.every((value) => typeof value === "string"); +}; + +const isAllNumber = (array: (string | number)[]): array is number[] => { + return array.every((value) => typeof value === "number"); +}; + export const multiSelectFilter = (cellValue: unknown | undefined, filterValue: MultiSelectFilterValue) => { const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue]; - if (!cellValueArray.every((value) => typeof value === "string")) { - return false; + + const filterValueArray = filterValue.data; + if (!filterValueArray || filterValueArray.length === 0) { + return true; } - return filterValue.data.length === 0 ? true : cellValueArray.some((v) => filterValue.data.includes(v)); + if (isAllString(filterValueArray) && isAllString(cellValueArray)) { + return cellValueArray.some((v) => filterValueArray.includes(v)); + } else if (isAllNumber(filterValueArray) && isAllNumber(cellValueArray)) { + return cellValueArray.some((v) => filterValueArray.includes(v)); + } + + return false; }; export const isMultiSelectFilterValue = (filterValue: unknown): filterValue is MultiSelectFilterValue => { @@ -166,7 +125,3 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => } return false; }; - -export const convertToTitleCase = (str: string) => { - return str.replace(/\b\w/g, (char) => char.toUpperCase()); -}; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 06f9443dcc..6bc8dabd59 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -8,12 +8,14 @@ import { getSortedRowModel, createColumnHelper, } from "@tanstack/react-table"; +// eslint-disable-next-line no-restricted-imports +import startCase from "lodash/startCase"; import Link from "next/link"; import { useRef, useMemo, useId } from "react"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import type { FilterValue, ExternalFilter } from "@calcom/features/data-table"; +import type { ExternalFilter } from "@calcom/features/data-table"; import { DataTableProvider, DataTable, @@ -24,7 +26,6 @@ import { multiSelectFilter, textFilter, dataTableFilter, - convertToTitleCase, useDataTable, } from "@calcom/features/data-table"; import classNames from "@calcom/lib/classNames"; @@ -47,33 +48,28 @@ import { import { useFilterContext } from "../context/provider"; import { ClearFilters } from "../filters/ClearFilters"; import { DateSelect } from "../filters/DateSelect"; -import { RoutingDownload } from "../filters/Download"; +import { RoutingFormResponsesDownload } from "../filters/Download"; import { RoutingFormFilterList } from "../filters/RoutingFormFilterList"; import { TeamAndSelfList } from "../filters/TeamAndSelfList"; import { UserListInTeam } from "../filters/UserListInTeam"; +import { + ZResponseMultipleValues, + ZResponseSingleValue, + ZResponseTextValue, + ZResponseNumericValue, +} from "../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; -type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; +type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; -type RoutingFormTableRow = { - id: number; - formName: string; - createdAt: Date; - routedToBooking: RoutingFormResponse["routedToBooking"]; - [key: string]: any; +const statusOrder: Record = { + [BookingStatus.ACCEPTED]: 1, + [BookingStatus.PENDING]: 2, + [BookingStatus.AWAITING_HOST]: 3, + [BookingStatus.CANCELLED]: 4, + [BookingStatus.REJECTED]: 5, }; -type FieldCellValue = { label: string; value: string }; - -const ZResponseValue = z.object({ - label: z.string(), - value: z.string(), -}); - -const ZResponseValues = z.array(ZResponseValue); - -type ResponseValues = FieldCellValue[]; - function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) { return (
@@ -110,7 +106,7 @@ function BookedByCell({ attendees, rowId, }: { - attendees: { email: string; timeZone: string }[] | undefined; + attendees: RoutingFormTableRow["bookingAttendees"] | undefined; rowId: number; }) { const cellId = useId(); @@ -120,8 +116,8 @@ function BookedByCell({
{attendees.map((attendee) => ( - - {attendee.email} + + {attendee.name} ))} @@ -129,7 +125,15 @@ function BookedByCell({ ); } -function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) { +function ResponseValueCell({ + optionMap, + values, + rowId, +}: { + optionMap: Record; + values: string[]; + rowId: number; +}) { const cellId = useId(); if (values.length === 0) return
; @@ -137,9 +141,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n {values.length > 2 ? ( <> - {values.slice(0, 2).map((value, i: number) => ( + {values.slice(0, 2).map((id: string, i: number) => ( - {value.label} + {optionMap[id] ?? id} ))} @@ -149,9 +153,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
- {values.slice(2).map((value, i: number) => ( + {values.slice(2).map((id: string, i: number) => ( - {value.label} + {optionMap[id] ?? id} ))}
@@ -160,9 +164,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
) : ( - values.map((value, i: number) => ( + values.map((id: string, i: number) => ( - {value.label} + {optionMap[id] ?? id} )) )} @@ -170,12 +174,12 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n ); } -function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedToBooking"] }) { +function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) { let badgeVariant: BadgeProps["variant"] = "success"; - if (!booking) return null; + if (!bookingStatus) return null; - switch (booking.status) { + switch (bookingStatus) { case BookingStatus.REJECTED: case BookingStatus.AWAITING_HOST: case BookingStatus.PENDING: @@ -184,23 +188,23 @@ function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedT break; } - return {bookingStatusToText(booking.status)}; + return {bookingStatusToText(bookingStatus)}; } function BookingAtCell({ - booking, + row, rowId, copyToClipboard, t, }: { - booking: RoutingFormResponse["routedToBooking"]; + row: RoutingFormTableRow; rowId: number; copyToClipboard: (text: string) => void; t: (key: string) => string; }) { const cellId = useId(); - if (!booking || !booking.user) { + if (!row.bookingUserId || !row.bookingCreatedAt) { return
; } @@ -208,9 +212,9 @@ function BookingAtCell({
- - - {dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")} + + + {dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}
@@ -218,17 +222,17 @@ function BookingAtCell({
- +
-

{booking.user.name}

+

{row.bookingUserName}

- {booking.user.email} + {row.bookingUserEmail} @@ -237,7 +241,7 @@ function BookingAtCell({

Status: - +
@@ -274,41 +278,47 @@ export function RoutingFormResponsesTableContent({ selectedRoutingFormId, selectedMemberUserId, selectedUserId, - selectedBookingStatus, - selectedRoutingFormFilter, } = filter; const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); const [startDate, endDate] = dateRange; const columnFilters = useColumnFilters(); + const teamId = selectedTeamId ?? undefined; + const userId = selectedUserId ?? undefined; + const memberUserId = selectedMemberUserId ?? undefined; + const routingFormId = selectedRoutingFormId ?? undefined; + const { data: headers, isLoading: isHeadersLoading, isSuccess: isHeadersSuccess, } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery( { - userId: selectedUserId ?? undefined, - teamId: selectedTeamId ?? undefined, + userId, + teamId, isAll: isAll ?? false, - routingFormId: selectedRoutingFormId ?? undefined, + routingFormId, }, { enabled: initialConfigIsReady, } ); + const { removeDisplayedExternalFilter, sorting, setSorting } = useDataTable(); + const { data, fetchNextPage, isFetching, hasNextPage, isLoading } = trpc.viewer.insights.routingFormResponses.useInfiniteQuery( { - teamId: selectedTeamId, + teamId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), - userId: selectedUserId ?? undefined, - memberUserId: selectedMemberUserId ?? undefined, + userId, + memberUserId, isAll: isAll ?? false, - routingFormId: selectedRoutingFormId ?? undefined, + routingFormId, columnFilters, + sorting, limit: 30, }, { @@ -321,60 +331,10 @@ export function RoutingFormResponsesTableContent({ } ); - const flatData = useMemo(() => data?.pages?.flatMap((page) => page.data) ?? [], [data]); - const totalDBRowCount = data?.pages?.[0]?.total ?? 0; - const totalFetched = flatData.length; - const processedData = useMemo(() => { if (!isHeadersSuccess) return []; - return flatData.map((response) => { - const row: RoutingFormTableRow = { - id: response.id, - formName: response.form.name, - formId: response.form.id, - createdAt: response.createdAt, - routedToBooking: response.routedToBooking, - }; - - Object.entries(response.response).forEach(([fieldId, field]) => { - const fieldHeader = (headers || []).find((h) => h.id === fieldId); - - if (fieldHeader?.options) { - if (Array.isArray(field.value)) { - // Map the IDs to their corresponding labels for array values - const labels = field.value - .map((id) => { - const option = fieldHeader.options?.find((opt) => opt.id === id); - if (!option) { - return undefined; - } - return { label: option.label, value: option.id }; - }) - .filter(Boolean); - row[fieldId] = labels; - } else { - // Handle single value case - const option = fieldHeader.options?.find((opt) => opt.id === field.value); - if (option) { - row[fieldId] = { label: option.label, value: option.id }; - } - } - } else { - row[fieldId] = field.value; - } - }); - - return row; - }); - }, [flatData, headers, isHeadersSuccess]); - - const statusOrder: Record = { - [BookingStatus.ACCEPTED]: 1, - [BookingStatus.PENDING]: 2, - [BookingStatus.AWAITING_HOST]: 3, - [BookingStatus.CANCELLED]: 4, - [BookingStatus.REJECTED]: 5, - }; + return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[]; + }, [data, isHeadersSuccess]); const columnHelper = createColumnHelper(); @@ -382,16 +342,15 @@ export function RoutingFormResponsesTableContent({ if (!isHeadersSuccess) { return []; } - return [ - columnHelper.accessor("routedToBooking", { - id: "bookedBy", + columnHelper.accessor("bookingAttendees", { + id: "bookingAttendees", header: t("routing_form_insights_booked_by"), size: 200, enableColumnFilter: false, + enableSorting: false, cell: (info) => { - const row = info.row.original; - return ; + return ; }, }), @@ -416,26 +375,39 @@ export function RoutingFormResponsesTableContent({ ? "text" : "multi_select"; - return columnHelper.accessor(fieldHeader.id, { + const optionMap = + fieldHeader.options?.reduce((acc, option) => { + if (option.id) { + acc[option.id] = option.label; + } + return acc; + }, {} as Record) ?? {}; + + return columnHelper.accessor(`response.${fieldHeader.id}`, { id: fieldHeader.id, - header: convertToTitleCase(fieldHeader.label), + header: startCase(fieldHeader.label), + enableSorting: false, cell: (info) => { const values = info.getValue(); - if (isMultiSelect) { - const result = ZResponseValues.safeParse(values); + if (isMultiSelect || isSingleSelect) { + const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values); return ( - result.success && + result.success && ( + + ) ); - } else if (isSingleSelect) { - const result = ZResponseValue.safeParse(values); + } else if (isText || isNumber) { + const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values); return ( - result.success && - ); - } else if (typeof values !== "object") { - return ( -
- {values} -
+ result.success && ( +
+ {result.data.value} +
+ ) ); } else { return null; @@ -444,79 +416,76 @@ export function RoutingFormResponsesTableContent({ meta: { filter: { type: filterType }, }, - filterFn: (row, id, filterValue: FilterValue) => { - let cellValue: unknown; - - if (Array.isArray(fieldHeader.options)) { - cellValue = Array.isArray(row.original[id]) - ? row.original[id].map((item: FieldCellValue) => item.value) - : row.original[id].value; - } else { - cellValue = row.original[id]; - } - + filterFn: (row, id, filterValue) => { + const cellValue = row.original.response[id].value; return dataTableFilter(cellValue, filterValue); }, }); }) ?? []), - columnHelper.accessor("routedToBooking", { - id: "bookingStatus", + columnHelper.accessor("bookingStatusOrder", { + id: "bookingStatusOrder", header: t("routing_form_insights_booking_status"), + sortDescFirst: false, cell: (info) => (
- +
), meta: { filter: { type: "multi_select", icon: "circle" }, }, filterFn: (row, id, filterValue) => { - return multiSelectFilter(row.original.routedToBooking?.status, filterValue); + return multiSelectFilter(row.original.bookingStatusOrder, filterValue); }, sortingFn: (rowA, rowB) => { - const statusA = rowA.original.routedToBooking?.status; - const statusB = rowB.original.routedToBooking?.status; - // Default to highest number (5) + 1 for null/undefined values to sort them last - const orderA = statusA ? statusOrder[statusA] : 6; - const orderB = statusB ? statusOrder[statusB] : 6; - return orderA - orderB; + const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null + const statusB = rowB.original.bookingStatusOrder ?? 6; + return statusA - statusB; }, }), - columnHelper.accessor("routedToBooking", { - id: "bookingAt", + columnHelper.accessor("bookingCreatedAt", { + id: "bookingCreatedAt", header: t("routing_form_insights_booking_at"), enableColumnFilter: false, cell: (info) => (
), + sortingFn: (rowA, rowB) => { + const dateA = rowA.original.bookingCreatedAt; + const dateB = rowB.original.bookingCreatedAt; + if (!dateA && !dateB) return 0; + if (!dateA) return -1; + if (!dateB) return 1; + if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0; + + return dateA.getTime() - dateB.getTime(); + }, }), - columnHelper.accessor("routedToBooking", { - id: "assignmentReason", + columnHelper.accessor("bookingAssignmentReason", { + id: "bookingAssignmentReason", header: t("routing_form_insights_assignment_reason"), + enableSorting: false, meta: { filter: { type: "text" }, }, cell: (info) => { - const assignmentReason = info.getValue()?.assignmentReason; - return ( -
- {assignmentReason && assignmentReason.length > 0 ? assignmentReason[0].reasonString : ""} -
- ); + const assignmentReason = info.getValue(); + return
{assignmentReason}
; }, filterFn: (row, id, filterValue) => { - return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue); + const reason = row.original.bookingAssignmentReason; + return textFilter(reason, filterValue); }, }), columnHelper.accessor("createdAt", { - id: "submittedAt", + id: "createdAt", header: t("routing_form_insights_submitted_at"), enableColumnFilter: false, cell: (info) => ( @@ -538,8 +507,10 @@ export function RoutingFormResponsesTableContent({ size: 150, }, state: { + sorting, columnFilters, }, + onSortingChange: setSorting, getFacetedUniqueValues: (_, columnId) => () => { if (!headers) { return new Map(); @@ -548,10 +519,13 @@ export function RoutingFormResponsesTableContent({ const fieldHeader = headers.find((h) => h.id === columnId); if (fieldHeader?.options) { return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1])); - } else if (columnId === "bookingStatus") { + } else if (columnId === "bookingStatusOrder") { return new Map( - Object.keys(BookingStatus).map((status) => [ - { value: status, label: bookingStatusToText(status as BookingStatus) }, + Object.keys(statusOrder).map((status) => [ + { + value: statusOrder[status as BookingStatus], + label: bookingStatusToText(status as BookingStatus), + }, 1, ]) ); @@ -567,8 +541,6 @@ export function RoutingFormResponsesTableContent({ isFetching, }); - const { removeDisplayedExternalFilter } = useDataTable(); - const externalFilters = useMemo( () => [ { @@ -610,7 +582,17 @@ export function RoutingFormResponsesTableContent({
- +
diff --git a/packages/features/insights/filters/Download/RoutingDownload.tsx b/packages/features/insights/filters/Download/RoutingDownload.tsx index 45684b91ae..4d570db647 100644 --- a/packages/features/insights/filters/Download/RoutingDownload.tsx +++ b/packages/features/insights/filters/Download/RoutingDownload.tsx @@ -21,7 +21,7 @@ const RoutingDownload = () => { const { t } = useLocale(); const [isDownloading, setIsDownloading] = useState(false); - const utils = trpc.useContext(); + const utils = trpc.useUtils(); const fetchBatch = async (cursor: number | null = null) => { const result = await utils.viewer.insights.rawRoutingData.fetch({ diff --git a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx new file mode 100644 index 0000000000..e217e7b826 --- /dev/null +++ b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; + +import type { Dayjs } from "@calcom/dayjs"; +import type { ColumnFilter, SortingState } from "@calcom/features/data-table"; +import { downloadAsCsv } from "@calcom/lib/csvUtils"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui"; +import { showToast } from "@calcom/ui"; + +type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number]; + +type Props = { + teamId: number | undefined; + userId: number | undefined; + memberUserId: number | undefined; + routingFormId: string | undefined; + isAll: boolean; + startDate: Dayjs; + endDate: Dayjs; + columnFilters: ColumnFilter[]; + sorting: SortingState; +}; + +type Batch = { + data: RoutingData[]; + nextCursor: number | undefined; +}; + +export const RoutingFormResponsesDownload = ({ + teamId, + userId, + memberUserId, + routingFormId, + isAll, + startDate, + endDate, + columnFilters, + sorting, +}: Props) => { + const { t } = useLocale(); + const [isDownloading, setIsDownloading] = useState(false); + + const utils = trpc.useUtils(); + + const fetchBatch = async ( + cursor: number | undefined = undefined + ): Promise<{ + data: RoutingData[]; + nextCursor: number | undefined; + }> => { + const { data, nextCursor } = await utils.viewer.insights.routingFormResponsesForDownload.fetch({ + teamId, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + userId, + memberUserId, + isAll: isAll ?? false, + routingFormId, + columnFilters, + sorting, + cursor, + }); + return { + data, + nextCursor, + }; + }; + + const handleDownloadClick = async () => { + try { + setIsDownloading(true); + let allData: RoutingData[] = []; + let hasMore = true; + let cursor: number | undefined = undefined; + + // Fetch data in batches until there's no more data + while (hasMore) { + const result: Batch = await fetchBatch(cursor); + allData = [...allData, ...result.data]; + hasMore = result.nextCursor !== undefined; + cursor = result.nextCursor; + } + + if (allData.length > 0) { + const filename = `RoutingFormResponses-${startDate.format("YYYY-MM-DD")}-${endDate.format( + "YYYY-MM-DD" + )}.csv`; + downloadAsCsv(allData as Record[], filename); + } + } catch (error) { + showToast(t("error_downloading_data"), "error"); + } finally { + setIsDownloading(false); + } + }; + + return ( + + + + + + {t("as_csv")} + + + ); +}; diff --git a/packages/features/insights/filters/Download/index.tsx b/packages/features/insights/filters/Download/index.tsx index c7d2759b3e..f5bf0b4d79 100644 --- a/packages/features/insights/filters/Download/index.tsx +++ b/packages/features/insights/filters/Download/index.tsx @@ -1,2 +1,3 @@ export { Download } from "./Download"; export { RoutingDownload } from "./RoutingDownload"; +export { RoutingFormResponsesDownload } from "./RoutingFormResponsesDownload"; diff --git a/packages/features/insights/lib/types.ts b/packages/features/insights/lib/types.ts new file mode 100644 index 0000000000..16ac6f72d0 --- /dev/null +++ b/packages/features/insights/lib/types.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const ZResponseMultipleValues = z.object({ + label: z.string(), + value: z.array(z.string()), +}); + +export const ZResponseSingleValue = z.object({ + label: z.string(), + value: z.string(), +}); + +export const ZResponseTextValue = z.object({ + label: z.string(), + value: z.string(), +}); + +export const ZResponseNumericValue = z.object({ + label: z.string(), + value: z.number(), +}); + +export const ZResponseValue = z.union([ + ZResponseMultipleValues, + ZResponseSingleValue, + ZResponseTextValue, + ZResponseNumericValue, +]); + +export type ResponseValue = z.infer; + +export const ZResponse = z.record(z.string(), ZResponseValue); diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index a6f3595dec..014f908082 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -1,15 +1,26 @@ import { Prisma } from "@prisma/client"; +// eslint-disable-next-line no-restricted-imports +import mapKeys from "lodash/mapKeys"; +// eslint-disable-next-line no-restricted-imports +import startCase from "lodash/startCase"; import { zodFields as routingFormFieldsSchema, routingFormResponseInDbSchema, } from "@calcom/app-store/routing-forms/zod"; import dayjs from "@calcom/dayjs"; -import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table"; -import { makeWhereClause } from "@calcom/features/data-table/lib/server"; +import type { + ColumnFilter, + TypedColumnFilter, + ColumnFilterType, + SortingState, +} from "@calcom/features/data-table"; +import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server"; import { readonlyPrisma as prisma } from "@calcom/prisma"; import type { BookingStatus } from "@calcom/prisma/enums"; +import { type ResponseValue, ZResponse } from "../lib/types"; + type RoutingFormInsightsTeamFilter = { userId?: number | null; teamId?: number | null; @@ -31,6 +42,8 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & { columnFilters: ColumnFilter[]; }; +type WhereForTeamOrAllTeams = Pick; + class RoutingEventsInsights { private static async getWhereForTeamOrAllTeams({ userId, @@ -38,7 +51,7 @@ class RoutingEventsInsights { isAll, organizationId, routingFormId, - }: RoutingFormInsightsTeamFilter) { + }: RoutingFormInsightsTeamFilter): Promise { // Get team IDs based on organization if applicable let teamIds: number[] = []; if (isAll && organizationId) { @@ -56,7 +69,7 @@ class RoutingEventsInsights { } // Base where condition for forms - const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = { + const formsWhereCondition: WhereForTeamOrAllTeams = { ...(teamIds.length > 0 ? { teamId: { @@ -211,7 +224,12 @@ class RoutingEventsInsights { userId, memberUserId, columnFilters, - }: Omit & { cursor?: number; limit?: number }) { + sorting, + }: Omit & { + sorting: SortingState; + cursor?: number; + limit?: number; + }) { const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({ userId, teamId, @@ -220,44 +238,57 @@ class RoutingEventsInsights { routingFormId, }); - const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus"); - const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as - | TypedColumnFilter<"text"> - | undefined; - const fieldFilters = columnFilters.filter( - (filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason" - ); - - let bookingWhereInput: Prisma.BookingWhereInput = {}; - if (memberUserId) { - bookingWhereInput.userId = memberUserId; - } - if (bookingStatusFilter) { - bookingWhereInput = { - ...bookingWhereInput, - ...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }), - }; - } - if (assignmentReasonFilter) { - const operator = assignmentReasonFilter.value.data.operator; - if (operator === "isEmpty") { - bookingWhereInput.assignmentReason = { none: {} }; - } else if (operator === "isNotEmpty") { - bookingWhereInput.assignmentReason = { - some: { - reasonString: { - not: "", - }, + const getLowercasedFilterValue = (filter: TypedColumnFilter) => { + if (filter.value.type === "text") { + return { + ...filter.value, + data: { + ...filter.value.data, + operand: filter.value.data.operand.toLowerCase(), }, }; - } else { - bookingWhereInput.assignmentReason = { - some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }), - }; } - } + return filter.value; + }; - const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = { + const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder"); + const bookingAssignmentReason = columnFilters.find( + (filter) => filter.id === "bookingAssignmentReason" + ) as TypedColumnFilter<"text"> | undefined; + const assignmentReasonValue = bookingAssignmentReason + ? getLowercasedFilterValue(bookingAssignmentReason) + : undefined; + + const responseFilters = columnFilters.filter( + (filter) => filter.id !== "bookingStatusOrder" && filter.id !== "bookingAssignmentReason" + ); + + const whereClause: Prisma.RoutingFormResponseWhereInput = { + ...(formsTeamWhereCondition.id !== undefined && { + formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">, + }), + ...(formsTeamWhereCondition.teamId !== undefined && { + formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">, + }), + ...(formsTeamWhereCondition.userId !== undefined && { + formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">, + }), + + // bookingStatus + ...(bookingStatusOrder && + makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })), + + // bookingAssignmentReason + ...(assignmentReasonValue && + makeWhereClause({ + columnName: "bookingAssignmentReasonLowercase", + filterValue: assignmentReasonValue, + })), + + // memberUserId + ...(memberUserId && { bookingUserId: memberUserId }), + + // createdAt ...(startDate && endDate && { createdAt: { @@ -265,77 +296,158 @@ class RoutingEventsInsights { lte: dayjs(endDate).endOf("day").toDate(), }, }), - ...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}), - form: formsTeamWhereCondition, + + // AND clause + ...(responseFilters.length > 0 && { + AND: responseFilters.map((fieldFilter) => { + return makeWhereClause({ + columnName: "responseLowercase", + filterValue: getLowercasedFilterValue(fieldFilter), + json: { path: [fieldFilter.id, "value"] }, + }); + }), + }), }; - if (fieldFilters.length > 0) { - responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => { - // NOTE: We cannot perform case-insensitive search on `response` column, - // until we normalize this table, use raw sql, or filter at the application level. - return makeWhereClause({ - columnName: "response", - filterValue: fieldFilter.value, - json: { path: [fieldFilter.id, "value"] }, - }); - }); - } - - const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({ - where: responsesWhereCondition, + const totalResponsePromise = prisma.routingFormResponse.count({ + where: whereClause, }); - const responsesPromise = prisma.app_RoutingForms_FormResponse.findMany({ + const responsesPromise = prisma.routingFormResponse.findMany({ select: { id: true, response: true, - form: { - select: { - id: true, - name: true, - }, - }, - routedToBooking: { - select: { - uid: true, - status: true, - createdAt: true, - attendees: { - select: { - timeZone: true, - email: true, - }, - }, - user: { - select: { id: true, name: true, email: true, avatarUrl: true }, - }, - assignmentReason: { - select: { reasonString: true }, - }, - }, - }, + formId: true, + formName: true, + bookingUid: true, + bookingStatus: true, + bookingStatusOrder: true, + bookingCreatedAt: true, + bookingAttendees: true, + bookingUserId: true, + bookingUserName: true, + bookingUserEmail: true, + bookingUserAvatarUrl: true, + bookingAssignmentReason: true, + bookingStartTime: true, + bookingEndTime: true, createdAt: true, }, - where: responsesWhereCondition, - orderBy: { - createdAt: "desc", - }, + where: whereClause, + orderBy: sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" }, take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages cursor: cursor ? { id: cursor } : undefined, }); const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]); - // Parse response data - const parsedResponses = responses.map((r) => { - const responseData = routingFormResponseInDbSchema.parse(r.response); - return { ...r, response: responseData }; - }); + const hasNextPage = responses.length > (limit ?? 0); + const responsesToReturn = responses.slice(0, limit ? limit : responses.length); + type Response = Omit< + (typeof responsesToReturn)[number], + "response" | "responseLowercase" | "bookingAttendees" + > & { + response: Record; + responseLowercase: Record; + bookingAttendees?: { name: string; email: string; timeZone: string }[]; + }; return { total: totalResponses, - data: parsedResponses, - nextCursor: responses.length > (limit ?? 0) ? responses[responses.length - 1].id : undefined, + data: responsesToReturn as Response[], + nextCursor: hasNextPage ? responsesToReturn[responsesToReturn.length - 1].id : undefined, + }; + } + + static async getRoutingFormPaginatedResponsesForDownload({ + teamId, + startDate, + endDate, + isAll, + organizationId, + routingFormId, + cursor, + limit, + userId, + memberUserId, + columnFilters, + sorting, + }: Omit & { + sorting: SortingState; + cursor?: number; + limit?: number; + }) { + const headersPromise = this.getRoutingFormHeaders({ + userId, + teamId, + isAll, + organizationId, + routingFormId, + }); + const dataPromise = this.getRoutingFormPaginatedResponses({ + teamId, + startDate, + endDate, + isAll, + organizationId, + routingFormId, + cursor, + userId, + memberUserId, + limit, + columnFilters, + sorting, + }); + + const [headers, data] = await Promise.all([headersPromise, dataPromise]); + + const dataWithFlatResponse = data.data.map((item) => { + const { bookingAttendees } = item; + const responseParseResult = ZResponse.safeParse(item.response); + const response = responseParseResult.success ? responseParseResult.data : {}; + + const fields = (headers || []).reduce((acc, header) => { + const id = header.id; + if (!response[id]) { + acc[header.label] = ""; + return acc; + } + if (header.type === "select") { + acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label; + } else if (header.type === "multiselect" && Array.isArray(response[id].value)) { + acc[header.label] = (response[id].value as string[]) + .map((value) => header.options?.find((option) => option.id === value)?.label) + .filter((label): label is string => label !== undefined) + .sort() + .join(", "); + } else { + acc[header.label] = response[id].value as string; + } + return acc; + }, {} as Record); + + return { + "Response ID": item.id, + "Form Name": item.formName, + "Submitted At": item.createdAt.toISOString(), + "Has Booking": item.bookingUid !== null, + "Booking Status": item.bookingStatus || "NO_BOOKING", + "Booking Created At": item.bookingCreatedAt?.toISOString() || "", + "Booking Start Time": item.bookingStartTime?.toISOString() || "", + "Booking End Time": item.bookingEndTime?.toISOString() || "", + "Attendee Name": (bookingAttendees as any)?.[0]?.name, + "Attendee Email": (bookingAttendees as any)?.[0]?.email, + "Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone, + "Assignment Reason": item.bookingAssignmentReason || "", + "Routed To Name": item.bookingUserName || "", + "Routed To Email": item.bookingUserEmail || "", + ...mapKeys(fields, (_, key) => startCase(key)), + }; + }); + + return { + data: dataWithFlatResponse, + nextCursor: data.nextCursor, }; } diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 0011790314..11a978f924 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -3,7 +3,7 @@ import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { ZColumnFilter } from "@calcom/features/data-table"; +import { ZColumnFilter, ZSorting } from "@calcom/features/data-table"; import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema"; import { randomString } from "@calcom/lib/random"; import type { readonlyPrisma } from "@calcom/prisma"; @@ -1601,6 +1601,7 @@ export const insightsRouter = router({ cursor: z.number().optional(), limit: z.number().optional(), columnFilters: z.array(ZColumnFilter), + sorting: z.array(ZSorting), }) ) .query(async ({ ctx, input }) => { @@ -1617,6 +1618,33 @@ export const insightsRouter = router({ memberUserId: input.memberUserId ?? null, limit: input.limit, columnFilters: input.columnFilters, + sorting: input.sorting, + }); + }), + routingFormResponsesForDownload: userBelongsToTeamProcedure + .input( + rawDataInputSchema.extend({ + routingFormId: z.string().optional(), + cursor: z.number().optional(), + limit: z.number().optional(), + columnFilters: z.array(ZColumnFilter), + sorting: z.array(ZSorting), + }) + ) + .query(async ({ ctx, input }) => { + return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({ + teamId: input.teamId ?? null, + startDate: input.startDate, + endDate: input.endDate, + isAll: input.isAll ?? false, + organizationId: ctx.user.organizationId ?? null, + routingFormId: input.routingFormId ?? null, + cursor: input.cursor, + userId: input.userId ?? null, + memberUserId: input.memberUserId ?? null, + limit: input.limit ?? BATCH_SIZE, + columnFilters: input.columnFilters, + sorting: input.sorting, }); }), getRoutingFormFieldOptions: userBelongsToTeamProcedure diff --git a/packages/prisma/migrations/20241212164338_create_routing_form_response/migration.sql b/packages/prisma/migrations/20241212164338_create_routing_form_response/migration.sql new file mode 100644 index 0000000000..2c5e76de89 --- /dev/null +++ b/packages/prisma/migrations/20241212164338_create_routing_form_response/migration.sql @@ -0,0 +1,72 @@ +CREATE OR REPLACE VIEW "RoutingFormResponse" AS +WITH attendees_agg AS ( + SELECT + a."bookingId", + json_agg( + json_build_object( + 'name', a.name, + 'timeZone', a."timeZone", + 'email', a.email + ) + ) as attendees + FROM "Attendee" a + GROUP BY a."bookingId" +), +assignment_reasons_agg AS ( + SELECT + ar."bookingId", + json_agg( + json_build_object( + 'reasonString', ar."reasonString" + ) + ) as reasons + FROM "AssignmentReason" ar + GROUP BY ar."bookingId" +) +SELECT + r.id, + r.response, + ( + SELECT jsonb_object_agg( + key, + CASE + WHEN jsonb_typeof(value->'value') = 'string' + THEN jsonb_build_object( + 'label', value->'label', + 'value', lower((value->>'value')::text) + ) + ELSE value + END + ) + FROM jsonb_each(r.response::jsonb) + ) as "responseLowercase", + f.id as "formId", + f.name as "formName", + f."teamId" as "formTeamId", + f."userId" as "formUserId", + b.uid as "bookingUid", + b.status as "bookingStatus", + CASE b.status + WHEN 'accepted' THEN 1 + WHEN 'pending' THEN 2 + WHEN 'awaiting_host' THEN 3 + WHEN 'cancelled' THEN 4 + WHEN 'rejected' THEN 5 + END as "bookingStatusOrder", + b."createdAt" as "bookingCreatedAt", + b."startTime" as "bookingStartTime", + b."endTime" as "bookingEndTime", + att.attendees as "bookingAttendees", + u.id as "bookingUserId", + u.name as "bookingUserName", + u.email as "bookingUserEmail", + u."avatarUrl" as "bookingUserAvatarUrl", + COALESCE((ar.reasons->0)->>'reasonString', '') as "bookingAssignmentReason", + LOWER(COALESCE((ar.reasons->0)->>'reasonString', '')) as "bookingAssignmentReasonLowercase", + r."createdAt" as "createdAt" +FROM "App_RoutingForms_FormResponse" r +LEFT JOIN "App_RoutingForms_Form" f ON r."formId" = f.id +LEFT JOIN "Booking" b ON r."routedToBookingUid" = b.uid +LEFT JOIN attendees_agg att ON b.id = att."bookingId" +LEFT JOIN "users" u ON b."userId" = u.id +LEFT JOIN assignment_reasons_agg ar ON b.id = ar."bookingId"; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 11fb0bad3e..67ac4ebbca 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1019,6 +1019,30 @@ model App_RoutingForms_FormResponse { @@index([formId]) } +view RoutingFormResponse { + id Int @unique + response Json + responseLowercase Json + formId String + formName String + formTeamId Int? + formUserId Int? + bookingUid String? + bookingStatus BookingStatus? + bookingStatusOrder Int? + bookingCreatedAt DateTime? + bookingAttendees Json? // Array of {timeZone: string, email: string} + bookingUserId Int? + bookingUserName String? + bookingUserEmail String? + bookingUserAvatarUrl String? + bookingAssignmentReason String? + bookingAssignmentReasonLowercase String? + bookingStartTime DateTime? + bookingEndTime DateTime? + createdAt DateTime +} + model Feedback { id Int @id @default(autoincrement()) date DateTime @default(now()) diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts index 8b417a954a..1b1b6749ef 100644 --- a/packages/prisma/seed-insights.ts +++ b/packages/prisma/seed-insights.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums"; import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils"; @@ -103,6 +103,35 @@ async function createAttendees(bookings: any[]) { } } +async function seedBookingAssignments() { + const assignmentReasons = [ + AssignmentReasonEnum.ROUTING_FORM_ROUTING, + AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK, + AssignmentReasonEnum.REASSIGNED, + AssignmentReasonEnum.REROUTED, + AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + ]; + // Get all booking IDs + const bookings = await prisma.booking.findMany({ + select: { + id: true, + }, + }); + + // Take 20% of bookings randomly + const numberOfBookingsToAssign = Math.floor(bookings.length * 0.2); + const randomBookings = bookings.sort(() => Math.random() - 0.5).slice(0, numberOfBookingsToAssign); + + // Create assignment reasons for the random bookings + await prisma.assignmentReason.createMany({ + data: randomBookings.map((booking) => ({ + bookingId: booking.id, + reasonString: faker.lorem.sentence(), + reasonEnum: assignmentReasons[Math.floor(Math.random() * assignmentReasons.length)], + })), + }); +} + const prisma = new PrismaClient(); async function main() { // First find the organization we want to add insights to @@ -307,6 +336,8 @@ async function main() { if (seededForm) { await seedRoutingFormResponses(seededForm, attributes, insightsTeam.id); } + + await seedBookingAssignments(); } main() .then(async () => { diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 6917aa3a4f..6db851d337 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -15,6 +15,10 @@ type GetOptions = { input: TListMembersSchema; }; +const isAllString = (array: (string | number)[]): array is string[] => { + return array.every((value) => typeof value === "string"); +}; + export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId; const searchTerm = input.searchTerm; @@ -103,7 +107,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { break; // We assume that if the filter is not one of the above, it must be an attribute filter default: - if (filter.value.type === "multi_select") { + if (filter.value.type === "multi_select" && isAllString(filter.value.data)) { const attributeOptionValues: string[] = []; filter.value.data.forEach((filterValueItem) => { attributeOptionValues.push(filterValueItem);