From 593db0675ed103726439ea77d2d937668e24177b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 23 Jul 2025 16:28:42 +0200 Subject: [PATCH 1/6] fix: refactor AverageEventDurationChart to use InsightsBookingService --- .../components/AverageEventDurationChart.tsx | 13 +- .../features/insights/server/trpc-router.ts | 130 ++++++++---------- .../lib/server/service/insightsBooking.ts | 102 +++++++++++++- 3 files changed, 168 insertions(+), 77 deletions(-) diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/AverageEventDurationChart.tsx index 145c1e461e7e3e..1285febfdfefe7 100644 --- a/packages/features/insights/components/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/AverageEventDurationChart.tsx @@ -1,4 +1,6 @@ +import { useDataTable } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; import { useInsightsParameters } from "../hooks/useInsightsParameters"; @@ -9,17 +11,18 @@ import { LoadingInsight } from "./LoadingInsights"; export const AverageEventDurationChart = () => { const { t } = useLocale(); - const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const { timeZone } = useDataTable(); const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery( { + scope, + selectedTeamId, startDate, endDate, - teamId, + timeZone: timeZone || CURRENT_TIMEZONE, eventTypeId, memberUserId, - userId, - isAll, }, { staleTime: 30000, @@ -32,7 +35,7 @@ export const AverageEventDurationChart = () => { if (isPending) return ; if (!isSuccess || !data) return null; - const isNoData = data.every((item) => item["Average"] === 0); + const isNoData = data.every((item: { Date: string; Average: number }) => item["Average"] === 0); return ( {isNoData && ( diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 8efac9a30715ad..72e131b92994f4 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -652,82 +652,76 @@ export const insightsRouter = router({ return result; }), - averageEventDuration: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, memberUserId, userId, eventTypeId, isAll } = input; + averageEventDuration: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input; - if (userId && ctx.user?.id !== userId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } + const insightsBookingService = new InsightsBookingService({ + prisma: ctx.insightsDb, + options: { + scope, + userId: ctx.user.id, + orgId: ctx.user.organizationId ?? 0, + ...(selectedTeamId && { teamId: selectedTeamId }), + }, + filters: { + ...(eventTypeId && { eventTypeId }), + ...(memberUserId && { memberUserId }), + startDate, + endDate, + }, + }); - if (!teamId && !userId) { - return []; - } + try { + const timeView = EventsInsights.getTimeView(startDate, endDate); + const dateRanges = EventsInsights.getDateRanges({ + startDate, + endDate, + timeView, + timeZone, + weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"], + }); - const { whereCondition: whereConditional } = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); + if (!dateRanges.length) { + return []; + } - const timeView = EventsInsights.getTimeView(startDate, endDate); - const dateRanges = EventsInsights.getDateRanges({ - startDate, - endDate, - timeView, - timeZone: ctx.user.timeZone, - weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"], - }); + const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week"; - if (!dateRanges.length) { - return []; - } + const allBookings = await insightsBookingService.findAll({ + select: { + eventLength: true, + createdAt: true, + }, + }); - const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week"; + const resultMap = new Map(); - const allBookings = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({ - select: { - eventLength: true, - createdAt: true, - }, - where: { - ...whereConditional, - createdAt: { - gte: startDate, - lte: endDate, - }, - }, - }); + // Initialize the map with all date ranges + for (const range of dateRanges) { + resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 }); + } - const resultMap = new Map(); + for (const booking of allBookings) { + const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll"); + if (resultMap.has(periodStart)) { + const current = resultMap.get(periodStart)!; + current.totalDuration += booking.eventLength || 0; + current.count += 1; + } + } - // Initialize the map with all date ranges - for (const range of dateRanges) { - resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 }); - } + const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({ + Date: date, + Average: count > 0 ? totalDuration / count : 0, + })); - for (const booking of allBookings) { - const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll"); - if (resultMap.has(periodStart)) { - const current = resultMap.get(periodStart)!; - current.totalDuration += booking.eventLength || 0; - current.count += 1; + return result; + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - } - - const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({ - Date: date, - Average: count > 0 ? totalDuration / count : 0, - })); - - return result; - }), + }), membersWithMostCancelledBookings: userBelongsToTeamProcedure .input(rawDataInputSchema) .query(async ({ ctx, input }) => { @@ -1750,11 +1744,7 @@ export const insightsRouter = router({ userId: ctx.user.id, orgId: ctx.user.organizationId, }, - filters: { - startDate: input.startDate, - endDate: input.endDate, - columnFilters: input.columnFilters, - }, + filters: {}, }); try { return await insightsRoutingService.getRoutingFunnelData(dateRanges); diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 5e841d20861005..85cb05146a1855 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -7,6 +7,52 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRepository } from "../repository/membership"; import { TeamRepository } from "../repository/team"; +// Type definition for BookingTimeStatusDenormalized view +export type BookingTimeStatusDenormalized = z.infer; + +// Helper type for select parameter +export type BookingSelect = { + [K in keyof BookingTimeStatusDenormalized]?: boolean; +}; + +// Zod schema for the actual data shape (matching Prisma schema) +export const bookingDataSchema = z + .object({ + id: z.number(), + uid: z.string(), + eventTypeId: z.number().nullable(), + title: z.string(), + description: z.string().nullable(), + startTime: z.date(), + endTime: z.date(), + createdAt: z.date(), + updatedAt: z.date().nullable(), + location: z.string().nullable(), + paid: z.boolean(), + status: z.string(), // BookingStatus enum + rescheduled: z.boolean().nullable(), + userId: z.number().nullable(), + teamId: z.number().nullable(), + eventLength: z.number().nullable(), + eventParentId: z.number().nullable(), + userEmail: z.string().nullable(), + userName: z.string().nullable(), + userUsername: z.string().nullable(), + ratingFeedback: z.string().nullable(), + rating: z.number().nullable(), + noShowHost: z.boolean().nullable(), + isTeamBooking: z.boolean(), + timeStatus: z.string().nullable(), + }) + .strict(); + +// Zod schema for runtime validation of select parameter (derived from data schema) +export const bookingSelectSchema = z + .object( + Object.fromEntries(Object.keys(bookingDataSchema.shape).map((key) => [key, z.boolean().optional()])) + ) + .strict(); + export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [ z.object({ scope: z.literal("user"), @@ -38,10 +84,15 @@ export type InsightsBookingServiceOptions = z.infer({ + select, + }: { + select?: TSelect; + } = {}): Promise< + Array>> + > { + const baseConditions = await this.getBaseConditions(); + + // Build the select clause with validated fields + let selectFields = "*"; + if (select) { + const keys = Object.keys(select); + if (keys.length === 0 || keys.some((key) => !bookingDataKeys.has(key))) { + throw new Error("Invalid select keys provided"); + } + + selectFields = keys.map((field) => `"${field}"`).join(", "); + } + + const results = await this.prisma.$queryRaw< + Array>> + >` + SELECT ${Prisma.raw(selectFields)} + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + `; + + return results; + } + async getBaseConditions(): Promise { const authConditions = await this.getAuthorizationConditions(); const filterConditions = await this.getFilterConditions(); @@ -155,6 +237,18 @@ export class InsightsBookingService { conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`); } + if (this.filters.startDate) { + conditions.push(Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp`); + } + + if (this.filters.endDate) { + conditions.push(Prisma.sql`"createdAt" <= ${this.filters.endDate}::timestamp`); + } + + if (this.filters.status) { + conditions.push(Prisma.sql`"status" = ${this.filters.status}`); + } + if (conditions.length === 0) { return null; } @@ -178,9 +272,13 @@ export class InsightsBookingService { if (this.options.scope === "user") { return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`; } else if (this.options.scope === "org") { - return await this.buildOrgAuthorizationCondition(this.options); + return await this.buildOrgAuthorizationCondition( + this.options as Extract + ); } else if (this.options.scope === "team") { - return await this.buildTeamAuthorizationCondition(this.options); + return await this.buildTeamAuthorizationCondition( + this.options as Extract + ); } else { return NOTHING_CONDITION; } From 87728c23a34785ff699c8164af0f6816ebcfba4e Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 23 Jul 2025 16:43:24 +0200 Subject: [PATCH 2/6] update implementations --- .../features/insights/server/trpc-router.ts | 21 +++-- .../lib/server/service/insightsBooking.ts | 85 ++++++++----------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 72e131b92994f4..3f3afbdd81de66 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -668,8 +668,11 @@ export const insightsRouter = router({ filters: { ...(eventTypeId && { eventTypeId }), ...(memberUserId && { memberUserId }), - startDate, - endDate, + dateRange: { + target: "createdAt", + startDate, + endDate, + }, }, }); @@ -1744,7 +1747,11 @@ export const insightsRouter = router({ userId: ctx.user.id, orgId: ctx.user.organizationId, }, - filters: {}, + filters: { + startDate: input.startDate, + endDate: input.endDate, + columnFilters: input.columnFilters, + }, }); try { return await insightsRoutingService.getRoutingFunnelData(dateRanges); @@ -1768,13 +1775,17 @@ export const insightsRouter = router({ filters: { ...(eventTypeId && { eventTypeId }), ...(memberUserId && { memberUserId }), + status: "accepted", + dateRange: { + target: "startTime", + startDate, + endDate, + }, }, }); try { return await insightsBookingService.getBookingsByHourStats({ - startDate, - endDate, timeZone, }); } catch (e) { diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 85cb05146a1855..31b8175b2968f1 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -15,7 +15,6 @@ export type BookingSelect = { [K in keyof BookingTimeStatusDenormalized]?: boolean; }; -// Zod schema for the actual data shape (matching Prisma schema) export const bookingDataSchema = z .object({ id: z.number(), @@ -46,13 +45,6 @@ export const bookingDataSchema = z }) .strict(); -// Zod schema for runtime validation of select parameter (derived from data schema) -export const bookingSelectSchema = z - .object( - Object.fromEntries(Object.keys(bookingDataSchema.shape).map((key) => [key, z.boolean().optional()])) - ) - .strict(); - export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [ z.object({ scope: z.literal("user"), @@ -81,13 +73,20 @@ export type InsightsBookingServicePublicOptions = { export type InsightsBookingServiceOptions = z.infer; -export type InsightsBookingServiceFilterOptions = { - eventTypeId?: number; - memberUserId?: number; - startDate?: string; - endDate?: string; - status?: string; -}; +export type InsightsBookingServiceFilterOptions = z.infer; + +export const insightsBookingServiceFilterOptionsSchema = z.object({ + eventTypeId: z.number().optional(), + memberUserId: z.number().optional(), + dateRange: z + .object({ + target: z.enum(["createdAt", "startTime"]), + startDate: z.string(), + endDate: z.string(), + }) + .optional(), + status: z.string().optional(), +}); const NOTHING_CONDITION = Prisma.sql`1=0`; @@ -96,7 +95,7 @@ const bookingDataKeys = new Set(Object.keys(bookingDataSchema.shape)); export class InsightsBookingService { private prisma: typeof readonlyPrisma; private options: InsightsBookingServiceOptions | null; - private filters?: InsightsBookingServiceFilterOptions; + private filters: InsightsBookingServiceFilterOptions | null; private cachedAuthConditions?: Prisma.Sql; private cachedFilterConditions?: Prisma.Sql | null; @@ -110,26 +109,14 @@ export class InsightsBookingService { filters?: InsightsBookingServiceFilterOptions; }) { this.prisma = prisma; - const validation = insightsBookingServiceOptionsSchema.safeParse(options); - this.options = validation.success ? validation.data : null; + const optionsValidated = insightsBookingServiceOptionsSchema.safeParse(options); + this.options = optionsValidated.success ? optionsValidated.data : null; - this.filters = filters; + const filtersValidated = insightsBookingServiceFilterOptionsSchema.safeParse(filters); + this.filters = filtersValidated.success ? filtersValidated.data : null; } - async getBookingsByHourStats({ - startDate, - endDate, - timeZone, - }: { - startDate: string; - endDate: string; - timeZone: string; - }) { - // Validate date formats - if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) { - throw new Error(`Invalid date format: ${startDate} - ${endDate}`); - } - + async getBookingsByHourStats({ timeZone }: { timeZone: string }) { const baseConditions = await this.getBaseConditions(); const results = await this.prisma.$queryRaw< @@ -143,9 +130,6 @@ export class InsightsBookingService { COUNT(*)::int as "count" FROM "BookingTimeStatusDenormalized" WHERE ${baseConditions} - AND "startTime" >= ${startDate}::timestamp - AND "startTime" <= ${endDate}::timestamp - AND "status" = 'accepted' GROUP BY 1 ORDER BY 1 `; @@ -237,12 +221,21 @@ export class InsightsBookingService { conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`); } - if (this.filters.startDate) { - conditions.push(Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp`); - } - - if (this.filters.endDate) { - conditions.push(Prisma.sql`"createdAt" <= ${this.filters.endDate}::timestamp`); + // Use dateRange object for date filtering + if (this.filters.dateRange) { + const { target, startDate, endDate } = this.filters.dateRange; + if (startDate) { + if (isNaN(Date.parse(startDate))) { + throw new Error(`Invalid date format: ${startDate}`); + } + conditions.push(Prisma.sql`"${Prisma.raw(target)}" >= ${startDate}::timestamp`); + } + if (endDate) { + if (isNaN(Date.parse(endDate))) { + throw new Error(`Invalid date format: ${endDate}`); + } + conditions.push(Prisma.sql`"${Prisma.raw(target)}" <= ${endDate}::timestamp`); + } } if (this.filters.status) { @@ -272,13 +265,9 @@ export class InsightsBookingService { if (this.options.scope === "user") { return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`; } else if (this.options.scope === "org") { - return await this.buildOrgAuthorizationCondition( - this.options as Extract - ); + return await this.buildOrgAuthorizationCondition(this.options); } else if (this.options.scope === "team") { - return await this.buildTeamAuthorizationCondition( - this.options as Extract - ); + return await this.buildTeamAuthorizationCondition(this.options); } else { return NOTHING_CONDITION; } From 39073f9820cc871e74031031293a7f5ec2014a2b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 23 Jul 2025 16:50:25 +0200 Subject: [PATCH 3/6] put status back to the booking hourly query --- .../insights/components/AverageEventDurationChart.tsx | 2 +- packages/features/insights/server/trpc-router.ts | 1 - packages/lib/server/service/insightsBooking.ts | 6 +----- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/AverageEventDurationChart.tsx index 1285febfdfefe7..9e0c40eb6e82e1 100644 --- a/packages/features/insights/components/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/AverageEventDurationChart.tsx @@ -35,7 +35,7 @@ export const AverageEventDurationChart = () => { if (isPending) return ; if (!isSuccess || !data) return null; - const isNoData = data.every((item: { Date: string; Average: number }) => item["Average"] === 0); + const isNoData = data.every((item) => item["Average"] === 0); return ( {isNoData && ( diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 3f3afbdd81de66..b2b4105266bef9 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1775,7 +1775,6 @@ export const insightsRouter = router({ filters: { ...(eventTypeId && { eventTypeId }), ...(memberUserId && { memberUserId }), - status: "accepted", dateRange: { target: "startTime", startDate, diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 31b8175b2968f1..73902e26c009a0 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -85,7 +85,6 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({ endDate: z.string(), }) .optional(), - status: z.string().optional(), }); const NOTHING_CONDITION = Prisma.sql`1=0`; @@ -130,6 +129,7 @@ export class InsightsBookingService { COUNT(*)::int as "count" FROM "BookingTimeStatusDenormalized" WHERE ${baseConditions} + AND "status" = 'accepted' GROUP BY 1 ORDER BY 1 `; @@ -238,10 +238,6 @@ export class InsightsBookingService { } } - if (this.filters.status) { - conditions.push(Prisma.sql`"status" = ${this.filters.status}`); - } - if (conditions.length === 0) { return null; } From ab9da4e2c5cda17720ac1783269279cf7923c607 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 23 Jul 2025 17:04:14 +0200 Subject: [PATCH 4/6] clean up --- packages/lib/server/service/insightsBooking.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 73902e26c009a0..7b418e761b7ff3 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -157,11 +157,13 @@ export class InsightsBookingService { let selectFields = "*"; if (select) { const keys = Object.keys(select); - if (keys.length === 0 || keys.some((key) => !bookingDataKeys.has(key))) { + if (keys.some((key) => !bookingDataKeys.has(key))) { throw new Error("Invalid select keys provided"); } - selectFields = keys.map((field) => `"${field}"`).join(", "); + if (keys.length > 0) { + selectFields = keys.map((field) => `"${field}"`).join(", "); + } } const results = await this.prisma.$queryRaw< From 63dfc18471bac1ce1ec42deb95ba0296c391d88f Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 23 Jul 2025 17:37:40 +0200 Subject: [PATCH 5/6] improve types --- .../lib/server/service/insightsBooking.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 7b418e761b7ff3..ca8e74623d6418 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -15,6 +15,15 @@ export type BookingSelect = { [K in keyof BookingTimeStatusDenormalized]?: boolean; }; +// Helper type for selected fields +export type SelectedFields = T extends undefined + ? BookingTimeStatusDenormalized + : { + [K in keyof T as T[K] extends true ? K : never]: K extends keyof BookingTimeStatusDenormalized + ? BookingTimeStatusDenormalized[K] + : never; + }; + export const bookingDataSchema = z .object({ id: z.number(), @@ -144,13 +153,11 @@ export class InsightsBookingService { })); } - async findAll({ + async findAll({ select, }: { select?: TSelect; - } = {}): Promise< - Array>> - > { + } = {}): Promise>> { const baseConditions = await this.getBaseConditions(); // Build the select clause with validated fields @@ -166,15 +173,11 @@ export class InsightsBookingService { } } - const results = await this.prisma.$queryRaw< - Array>> - >` + return await this.prisma.$queryRaw>>` SELECT ${Prisma.raw(selectFields)} FROM "BookingTimeStatusDenormalized" WHERE ${baseConditions} `; - - return results; } async getBaseConditions(): Promise { From a5ebd3fe531024bb1136169c006cc83068448974 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 24 Jul 2025 10:41:45 +0200 Subject: [PATCH 6/6] address feedback --- packages/lib/server/service/insightsBooking.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index ca8e74623d6418..c52c6a4e2ad0e1 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -161,7 +161,7 @@ export class InsightsBookingService { const baseConditions = await this.getBaseConditions(); // Build the select clause with validated fields - let selectFields = "*"; + let selectFields = Prisma.sql`*`; if (select) { const keys = Object.keys(select); if (keys.some((key) => !bookingDataKeys.has(key))) { @@ -169,12 +169,14 @@ export class InsightsBookingService { } if (keys.length > 0) { - selectFields = keys.map((field) => `"${field}"`).join(", "); + // Use Prisma.sql for each field to ensure proper escaping + const sqlFields = keys.map((field) => Prisma.sql`"${Prisma.raw(field)}"`); + selectFields = Prisma.join(sqlFields, ", "); } } return await this.prisma.$queryRaw>>` - SELECT ${Prisma.raw(selectFields)} + SELECT ${selectFields} FROM "BookingTimeStatusDenormalized" WHERE ${baseConditions} `;