fix: refactor AverageEventDurationChart to use InsightsBookingService (#22702)

This commit is contained in:
Eunjae Lee
2025-07-25 08:59:11 +01:00
committed by GitHub
parent 000324c02d
commit cd50719b03
3 changed files with 193 additions and 100 deletions
@@ -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,
@@ -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" });
}
if (!teamId && !userId) {
return [];
}
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,
},
});
const timeView = EventsInsights.getTimeView(startDate, endDate);
const dateRanges = EventsInsights.getDateRanges({
startDate,
endDate,
timeView,
timeZone: ctx.user.timeZone,
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
});
if (!dateRanges.length) {
return [];
}
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const allBookings = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({
select: {
eventLength: true,
createdAt: true,
},
where: {
...whereConditional,
createdAt: {
gte: startDate,
lte: endDate,
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,
},
},
});
const resultMap = new Map<string, { totalDuration: number; count: number }>();
try {
const timeView = EventsInsights.getTimeView(startDate, endDate);
const dateRanges = EventsInsights.getDateRanges({
startDate,
endDate,
timeView,
timeZone,
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
});
// Initialize the map with all date ranges
for (const range of dateRanges) {
resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 });
}
if (!dateRanges.length) {
return [];
}
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;
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const allBookings = await insightsBookingService.findAll({
select: {
eventLength: true,
createdAt: true,
},
});
const resultMap = new Map<string, { totalDuration: number; count: number }>();
// Initialize the map with all date ranges
for (const range of dateRanges) {
resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, 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;
}
}
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
Date: date,
Average: count > 0 ? totalDuration / count : 0,
}));
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) {
+114 -24
View File
@@ -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<typeof bookingDataSchema>;
// Helper type for select parameter
export type BookingSelect = {
[K in keyof BookingTimeStatusDenormalized]?: boolean;
};
// Helper type for selected fields
export type SelectedFields<T> = 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<typeof insightsBookingServiceOptionsSchema>;
export type InsightsBookingServiceFilterOptions = {
eventTypeId?: number;
memberUserId?: number;
};
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
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<TSelect extends BookingSelect | undefined = undefined>({
select,
}: {
select?: TSelect;
} = {}): Promise<Array<SelectedFields<TSelect>>> {
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<Array<SelectedFields<TSelect>>>`
SELECT ${selectFields}
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
`;
}
async getBaseConditions(): Promise<Prisma.Sql> {
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;
}