From a2205d497d71aa6f000cda97d297213afc77455c Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 4 Aug 2025 15:48:17 +0200 Subject: [PATCH] feat: add booking status filter to /insights (#22842) Co-authored-by: Alex van Andel --- apps/web/playwright/insights.e2e.ts | 22 ++--- apps/web/public/static/locales/en/common.json | 1 + .../routing/RoutingFormResponsesTable.tsx | 9 +- .../useInsightsBookingFacetedUniqueValues.ts | 76 +++++++++++++++++ .../hooks/useInsightsBookingParameters.ts | 9 +- .../insights/hooks/useInsightsBookings.ts | 48 +++++++---- ... useInsightsRoutingFacetedUniqueValues.ts} | 2 +- .../insights/server/raw-data.schema.ts | 3 +- .../features/insights/server/trpc-router.ts | 5 +- .../insightsBooking.integration-test.ts | 85 +++++++++++++++++-- .../lib/server/service/insightsBooking.ts | 45 +++++++--- 11 files changed, 243 insertions(+), 62 deletions(-) create mode 100644 packages/features/insights/hooks/useInsightsBookingFacetedUniqueValues.ts rename packages/features/insights/hooks/{useInsightsFacetedUniqueValues.ts => useInsightsRoutingFacetedUniqueValues.ts} (98%) diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index d245f20f9e..6b3a169010 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -203,32 +203,24 @@ test.describe("Insights", async () => { await page.keyboard.press("Escape"); // Choose User filter item from dropdown - await addFilter(page, "bookingUserId"); + await addFilter(page, "userId"); - // Wait for the URL to include bookingUserId - await page.waitForURL((url) => url.toString().includes("bookingUserId")); + // Wait for the URL to include userId + await page.waitForURL((url) => url.toString().includes("userId")); // Click User filter to see a user list - await openFilter(page, "bookingUserId"); + await openFilter(page, "userId"); - await page - .locator('[data-testid="select-filter-options-bookingUserId"]') - .getByRole("option") - .nth(0) - .click(); + await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click(); - await page - .locator('[data-testid="select-filter-options-bookingUserId"]') - .getByRole("option") - .nth(1) - .click(); + await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click(); // press escape button to close the filter await page.keyboard.press("Escape"); await clearFilters(page); - await expect(page.url()).not.toContain("bookingUserId"); + await expect(page.url()).not.toContain("userId"); }); test("should test download button", async ({ page, users }) => { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fe8f9ffdd5..a1facd6724 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3429,5 +3429,6 @@ "usage_based_expiration_description": "This link can be used for {{count}} booking", "usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings", "usage_based_expiration_description_plural": "This link can be used for {{count}} bookings", + "booking_status": "Booking status", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx index 936493791d..6ed7a5f70a 100644 --- a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx @@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc"; import { RoutingFormResponsesDownload } from "../../filters/Download"; import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter"; import { useInsightsColumns } from "../../hooks/useInsightsColumns"; -import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues"; import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues"; import type { RoutingFormTableRow } from "../../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; @@ -49,7 +49,12 @@ export function RoutingFormResponsesTable() { routingFormId, }); - const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll }); + const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({ + headers, + userId, + teamId, + isAll, + }); const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable(); diff --git a/packages/features/insights/hooks/useInsightsBookingFacetedUniqueValues.ts b/packages/features/insights/hooks/useInsightsBookingFacetedUniqueValues.ts new file mode 100644 index 0000000000..f399c53e84 --- /dev/null +++ b/packages/features/insights/hooks/useInsightsBookingFacetedUniqueValues.ts @@ -0,0 +1,76 @@ +import type { Table } from "@tanstack/react-table"; +import { useCallback } from "react"; + +import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; + +import { bookingStatusToText } from "../lib/bookingStatusToText"; + +const statusOrder: Record = { + [BookingStatus.ACCEPTED]: 1, + [BookingStatus.PENDING]: 2, + [BookingStatus.AWAITING_HOST]: 3, + [BookingStatus.CANCELLED]: 4, + [BookingStatus.REJECTED]: 5, +}; + +export const useInsightsBookingFacetedUniqueValues = ({ + userId, + teamId, + isAll, +}: { + userId: number | undefined; + teamId: number | undefined; + isAll: boolean; +}) => { + const { data: users } = trpc.viewer.insights.userList.useQuery( + { + teamId, + isAll, + }, + { + refetchOnWindowFocus: false, + } + ); + + const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery( + { + teamId, + userId, + isAll, + }, + { + refetchOnWindowFocus: false, + } + ); + + return useCallback( + (_: Table, columnId: string) => (): Map => { + if (columnId === "status") { + return convertFacetedValuesToMap( + Object.keys(statusOrder).map((status) => ({ + value: status.toLowerCase(), + label: bookingStatusToText(status as BookingStatus), + })) + ); + } else if (columnId === "userId") { + return convertFacetedValuesToMap( + users?.map((user) => ({ + label: user.name ?? user.email, + value: user.id, + })) ?? [] + ); + } else if (columnId === "eventTypeId") { + return convertFacetedValuesToMap( + eventTypes?.map((eventType) => ({ + value: eventType.id, + label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title, + })) ?? [] + ); + } + return new Map(); + }, + [users, eventTypes] + ); +}; diff --git a/packages/features/insights/hooks/useInsightsBookingParameters.ts b/packages/features/insights/hooks/useInsightsBookingParameters.ts index f3fd61a1b7..cbe87615cb 100644 --- a/packages/features/insights/hooks/useInsightsBookingParameters.ts +++ b/packages/features/insights/hooks/useInsightsBookingParameters.ts @@ -1,8 +1,9 @@ import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; -import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table"; +import { ZDateRangeFilterValue } from "@calcom/features/data-table"; import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; +import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters"; import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable"; import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue"; import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange"; @@ -14,8 +15,6 @@ export function useInsightsBookingParameters() { const { scope, selectedTeamId } = useInsightsOrgTeams(); const { timeZone } = useDataTable(); - const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined; - const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined; const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data; // TODO for future: this preserving local time & startOf & endOf should be handled // from DateRangeFilter out of the box. @@ -34,6 +33,7 @@ export function useInsightsBookingParameters() { .toISOString(); }, [createdAtRange?.endDate]) ); + const columnFilters = useColumnFilters({ exclude: ["createdAt"] }); return { scope, @@ -41,7 +41,6 @@ export function useInsightsBookingParameters() { startDate, endDate, timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, + columnFilters, }; } diff --git a/packages/features/insights/hooks/useInsightsBookings.ts b/packages/features/insights/hooks/useInsightsBookings.ts index 256bf12937..d087b1d6e0 100644 --- a/packages/features/insights/hooks/useInsightsBookings.ts +++ b/packages/features/insights/hooks/useInsightsBookings.ts @@ -3,25 +3,24 @@ import { useMemo } from "react"; import { ColumnFilterType } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { BookingStatus } from "@calcom/prisma/enums"; -import type { HeaderRow, RoutingFormTableRow } from "../lib/types"; -import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues"; +import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues"; import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; type DummyTableRow = { - bookingUserId: RoutingFormTableRow["bookingUserId"]; + userId: number | null; eventTypeId: number | null; + status: BookingStatus; }; const emptyData: DummyTableRow[] = []; -const dummyHeaders: HeaderRow[] = []; export const useInsightsBookings = () => { const { t } = useLocale(); const { isAll, teamId, userId } = useInsightsOrgTeams(); - const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ - headers: dummyHeaders, + const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({ userId, teamId, isAll, @@ -30,18 +29,6 @@ export const useInsightsBookings = () => { const columns = useMemo(() => { const columnHelper = createColumnHelper(); return [ - columnHelper.accessor("bookingUserId", { - id: "bookingUserId", - header: t("user"), - enableColumnFilter: true, - enableSorting: false, - meta: { - filter: { - type: ColumnFilterType.SINGLE_SELECT, - }, - }, - cell: () => null, - }), columnHelper.accessor("eventTypeId", { id: "eventTypeId", header: t("event_type"), @@ -55,6 +42,31 @@ export const useInsightsBookings = () => { enableSorting: false, cell: () => null, }), + columnHelper.accessor("status", { + id: "status", + header: t("booking_status"), + size: 200, + meta: { + filter: { + type: ColumnFilterType.MULTI_SELECT, + }, + }, + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + }), + columnHelper.accessor("userId", { + id: "userId", + header: t("member"), + enableColumnFilter: true, + enableSorting: false, + meta: { + filter: { + type: ColumnFilterType.SINGLE_SELECT, + }, + }, + cell: () => null, + }), ]; }, [t]); diff --git a/packages/features/insights/hooks/useInsightsFacetedUniqueValues.ts b/packages/features/insights/hooks/useInsightsRoutingFacetedUniqueValues.ts similarity index 98% rename from packages/features/insights/hooks/useInsightsFacetedUniqueValues.ts rename to packages/features/insights/hooks/useInsightsRoutingFacetedUniqueValues.ts index 91ecd67cce..d71a51d04e 100644 --- a/packages/features/insights/hooks/useInsightsFacetedUniqueValues.ts +++ b/packages/features/insights/hooks/useInsightsRoutingFacetedUniqueValues.ts @@ -16,7 +16,7 @@ const statusOrder: Record = { [BookingStatus.REJECTED]: 5, }; -export const useInsightsFacetedUniqueValues = ({ +export const useInsightsRoutingFacetedUniqueValues = ({ headers, userId, teamId, diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index 1fd604dd07..52aacce049 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({ startDate: z.string(), endDate: z.string(), timeZone: z.string(), - eventTypeId: z.coerce.number().optional().nullable(), - memberUserId: z.coerce.number().optional().nullable(), + columnFilters: z.array(ZColumnFilter).optional(), }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 22c707c58d..aef25e394e 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -320,7 +320,7 @@ function createInsightsBookingService( input: z.infer, dateTarget: "createdAt" | "startTime" = "createdAt" ) { - const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input; + const { scope, selectedTeamId, startDate, endDate, columnFilters } = input; return new InsightsBookingService({ prisma: ctx.insightsDb, @@ -331,8 +331,7 @@ function createInsightsBookingService( ...(selectedTeamId && { teamId: selectedTeamId }), }, filters: { - ...(eventTypeId && { eventTypeId }), - ...(memberUserId && { memberUserId }), + ...(columnFilters && { columnFilters }), dateRange: { target: dateTarget, startDate, diff --git a/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts index fabd45af99..1d9940b360 100644 --- a/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts +++ b/packages/lib/server/service/__tests__/insightsBooking.integration-test.ts @@ -2,6 +2,7 @@ import type { Team, User, Membership } from "@prisma/client"; import { Prisma } from "@prisma/client"; 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"; @@ -376,7 +377,15 @@ describe("InsightsBookingService Integration Tests", () => { orgId: testData.org.id, }, filters: { - eventTypeId: testData.eventType.id, + columnFilters: [ + { + id: "eventTypeId", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: testData.eventType.id, + }, + }, + ], }, }); @@ -388,7 +397,7 @@ describe("InsightsBookingService Integration Tests", () => { await testData.cleanup(); }); - it("should build memberUserId filter conditions", async () => { + it("should build userId filter conditions", async () => { const testData = await createTestData(); const service = new InsightsBookingService({ @@ -399,7 +408,15 @@ describe("InsightsBookingService Integration Tests", () => { orgId: testData.org.id, }, filters: { - memberUserId: testData.user.id, + columnFilters: [ + { + id: "userId", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: testData.user.id, + }, + }, + ], }, }); @@ -420,8 +437,22 @@ describe("InsightsBookingService Integration Tests", () => { orgId: testData.org.id, }, filters: { - eventTypeId: testData.eventType.id, - memberUserId: testData.user.id, + columnFilters: [ + { + id: "eventTypeId", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: testData.eventType.id, + }, + }, + { + id: "userId", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: testData.user.id, + }, + }, + ], }, }); @@ -432,6 +463,40 @@ describe("InsightsBookingService Integration Tests", () => { await testData.cleanup(); }); + + it("should build status filter conditions", async () => { + const testData = await createTestData(); + + const service = new InsightsBookingService({ + prisma, + options: { + scope: "user", + userId: testData.user.id, + orgId: testData.org.id, + }, + filters: { + columnFilters: [ + { + id: "status", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: ["pending", "accepted"], + }, + }, + ], + }, + }); + + const conditions = await service.getFilterConditions(); + expect(conditions).toEqual( + Prisma.sql`"status" IN (${Prisma.join([ + Prisma.sql`${"pending"}::"BookingStatus"`, + Prisma.sql`${"accepted"}::"BookingStatus"`, + ])})` + ); + + await testData.cleanup(); + }); }); describe("getBaseConditions", () => { @@ -473,7 +538,15 @@ describe("InsightsBookingService Integration Tests", () => { orgId: testData.org.id, }, filters: { - eventTypeId: userEventType.id, + columnFilters: [ + { + id: "eventTypeId", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: userEventType.id, + }, + }, + ], }, }); diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index d76e02a373..d392441f46 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -3,6 +3,9 @@ import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import { ZColumnFilter } from "@calcom/features/data-table/lib/types"; +import type { ColumnFilter } from "@calcom/features/data-table/lib/types"; +import { isSingleSelectFilterValue, isMultiSelectFilterValue } from "@calcom/features/data-table/lib/utils"; import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -105,8 +108,6 @@ export type InsightsBookingServiceOptions = z.infer; export const insightsBookingServiceFilterOptionsSchema = z.object({ - eventTypeId: z.number().optional(), - memberUserId: z.number().optional(), dateRange: z .object({ target: z.enum(["createdAt", "startTime"]), @@ -114,6 +115,7 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({ endDate: z.string(), }) .optional(), + columnFilters: z.array(ZColumnFilter).optional(), }); const NOTHING_CONDITION = Prisma.sql`1=0`; @@ -238,14 +240,14 @@ export class InsightsBookingService { return null; } - if (this.filters.eventTypeId) { - conditions.push( - Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})` - ); - } - - if (this.filters.memberUserId) { - conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`); + // Process columnFilters using type-safe utility functions + if (this.filters.columnFilters) { + for (const filter of this.filters.columnFilters) { + const condition = this.buildColumnFilterCondition(filter); + if (condition) { + conditions.push(condition); + } + } } // Use dateRange object for date filtering @@ -276,6 +278,29 @@ export class InsightsBookingService { }); } + private buildColumnFilterCondition(filter: ColumnFilter): Prisma.Sql | null { + const { id, value } = filter; + + if (!value) { + return null; + } + + if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") { + return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`; + } + + if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") { + return Prisma.sql`"userId" = ${value.data}`; + } + + if (id === "status" && isMultiSelectFilterValue(value)) { + const statusValues = value.data.map((status) => Prisma.sql`${status}::"BookingStatus"`); + return Prisma.sql`"status" IN (${Prisma.join(statusValues)})`; + } + + return null; + } + async buildAuthorizationConditions(): Promise { if (!this.options) { return NOTHING_CONDITION;