diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/AverageEventDurationChart.tsx index 145c1e461e7e3e..9e0c40eb6e82e1 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, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 8efac9a30715ad..b2b4105266bef9 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -652,82 +652,79 @@ 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 }), + dateRange: { + target: "createdAt", + 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 }) => { @@ -1778,13 +1775,16 @@ export const insightsRouter = router({ filters: { ...(eventTypeId && { eventTypeId }), ...(memberUserId && { memberUserId }), + 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 5e841d20861005..c52c6a4e2ad0e1 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -7,6 +7,53 @@ 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; +}; + +// 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(), + 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(); + export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [ z.object({ scope: z.literal("user"), @@ -35,17 +82,28 @@ export type InsightsBookingServicePublicOptions = { export type InsightsBookingServiceOptions = z.infer; -export type InsightsBookingServiceFilterOptions = { - eventTypeId?: number; - memberUserId?: number; -}; +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(), +}); const NOTHING_CONDITION = Prisma.sql`1=0`; +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; @@ -59,26 +117,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< @@ -92,8 +138,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 @@ -109,6 +153,35 @@ export class InsightsBookingService { })); } + async findAll({ + select, + }: { + select?: TSelect; + } = {}): Promise>> { + const baseConditions = await this.getBaseConditions(); + + // Build the select clause with validated fields + let selectFields = Prisma.sql`*`; + if (select) { + const keys = Object.keys(select); + if (keys.some((key) => !bookingDataKeys.has(key))) { + throw new Error("Invalid select keys provided"); + } + + if (keys.length > 0) { + // 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 ${selectFields} + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + `; + } + async getBaseConditions(): Promise { const authConditions = await this.getAuthorizationConditions(); const filterConditions = await this.getFilterConditions(); @@ -155,6 +228,23 @@ export class InsightsBookingService { conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`); } + // 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 (conditions.length === 0) { return null; }