Skip to content

Commit cd50719

Browse files
authored
fix: refactor AverageEventDurationChart to use InsightsBookingService (calcom#22702)
1 parent 000324c commit cd50719

3 files changed

Lines changed: 188 additions & 95 deletions

File tree

packages/features/insights/components/AverageEventDurationChart.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { useDataTable } from "@calcom/features/data-table";
12
import { useLocale } from "@calcom/lib/hooks/useLocale";
3+
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
24
import { trpc } from "@calcom/trpc";
35

46
import { useInsightsParameters } from "../hooks/useInsightsParameters";
@@ -9,17 +11,18 @@ import { LoadingInsight } from "./LoadingInsights";
911

1012
export const AverageEventDurationChart = () => {
1113
const { t } = useLocale();
12-
const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
14+
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
15+
const { timeZone } = useDataTable();
1316

1417
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
1518
{
19+
scope,
20+
selectedTeamId,
1621
startDate,
1722
endDate,
18-
teamId,
23+
timeZone: timeZone || CURRENT_TIMEZONE,
1924
eventTypeId,
2025
memberUserId,
21-
userId,
22-
isAll,
2326
},
2427
{
2528
staleTime: 30000,

packages/features/insights/server/trpc-router.ts

Lines changed: 67 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -652,82 +652,79 @@ export const insightsRouter = router({
652652

653653
return result;
654654
}),
655-
averageEventDuration: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
656-
const { teamId, startDate, endDate, memberUserId, userId, eventTypeId, isAll } = input;
655+
averageEventDuration: userBelongsToTeamProcedure
656+
.input(bookingRepositoryBaseInputSchema)
657+
.query(async ({ ctx, input }) => {
658+
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
657659

658-
if (userId && ctx.user?.id !== userId) {
659-
throw new TRPCError({ code: "UNAUTHORIZED" });
660-
}
660+
const insightsBookingService = new InsightsBookingService({
661+
prisma: ctx.insightsDb,
662+
options: {
663+
scope,
664+
userId: ctx.user.id,
665+
orgId: ctx.user.organizationId ?? 0,
666+
...(selectedTeamId && { teamId: selectedTeamId }),
667+
},
668+
filters: {
669+
...(eventTypeId && { eventTypeId }),
670+
...(memberUserId && { memberUserId }),
671+
dateRange: {
672+
target: "createdAt",
673+
startDate,
674+
endDate,
675+
},
676+
},
677+
});
661678

662-
if (!teamId && !userId) {
663-
return [];
664-
}
679+
try {
680+
const timeView = EventsInsights.getTimeView(startDate, endDate);
681+
const dateRanges = EventsInsights.getDateRanges({
682+
startDate,
683+
endDate,
684+
timeView,
685+
timeZone,
686+
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
687+
});
665688

666-
const { whereCondition: whereConditional } = await buildBaseWhereCondition({
667-
teamId,
668-
eventTypeId: eventTypeId ?? undefined,
669-
memberUserId: memberUserId ?? undefined,
670-
userId: userId ?? undefined,
671-
isAll: isAll ?? false,
672-
ctx: {
673-
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
674-
userOrganizationId: ctx.user.organizationId,
675-
insightsDb: ctx.insightsDb,
676-
},
677-
});
689+
if (!dateRanges.length) {
690+
return [];
691+
}
678692

679-
const timeView = EventsInsights.getTimeView(startDate, endDate);
680-
const dateRanges = EventsInsights.getDateRanges({
681-
startDate,
682-
endDate,
683-
timeView,
684-
timeZone: ctx.user.timeZone,
685-
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
686-
});
693+
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
687694

688-
if (!dateRanges.length) {
689-
return [];
690-
}
695+
const allBookings = await insightsBookingService.findAll({
696+
select: {
697+
eventLength: true,
698+
createdAt: true,
699+
},
700+
});
691701

692-
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
702+
const resultMap = new Map<string, { totalDuration: number; count: number }>();
693703

694-
const allBookings = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({
695-
select: {
696-
eventLength: true,
697-
createdAt: true,
698-
},
699-
where: {
700-
...whereConditional,
701-
createdAt: {
702-
gte: startDate,
703-
lte: endDate,
704-
},
705-
},
706-
});
704+
// Initialize the map with all date ranges
705+
for (const range of dateRanges) {
706+
resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 });
707+
}
707708

708-
const resultMap = new Map<string, { totalDuration: number; count: number }>();
709+
for (const booking of allBookings) {
710+
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
711+
if (resultMap.has(periodStart)) {
712+
const current = resultMap.get(periodStart)!;
713+
current.totalDuration += booking.eventLength || 0;
714+
current.count += 1;
715+
}
716+
}
709717

710-
// Initialize the map with all date ranges
711-
for (const range of dateRanges) {
712-
resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 });
713-
}
718+
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
719+
Date: date,
720+
Average: count > 0 ? totalDuration / count : 0,
721+
}));
714722

715-
for (const booking of allBookings) {
716-
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
717-
if (resultMap.has(periodStart)) {
718-
const current = resultMap.get(periodStart)!;
719-
current.totalDuration += booking.eventLength || 0;
720-
current.count += 1;
723+
return result;
724+
} catch (e) {
725+
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
721726
}
722-
}
723-
724-
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
725-
Date: date,
726-
Average: count > 0 ? totalDuration / count : 0,
727-
}));
728-
729-
return result;
730-
}),
727+
}),
731728
membersWithMostCancelledBookings: userBelongsToTeamProcedure
732729
.input(rawDataInputSchema)
733730
.query(async ({ ctx, input }) => {
@@ -1778,13 +1775,16 @@ export const insightsRouter = router({
17781775
filters: {
17791776
...(eventTypeId && { eventTypeId }),
17801777
...(memberUserId && { memberUserId }),
1778+
dateRange: {
1779+
target: "startTime",
1780+
startDate,
1781+
endDate,
1782+
},
17811783
},
17821784
});
17831785

17841786
try {
17851787
return await insightsBookingService.getBookingsByHourStats({
1786-
startDate,
1787-
endDate,
17881788
timeZone,
17891789
});
17901790
} catch (e) {

packages/lib/server/service/insightsBooking.ts

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,53 @@ import { MembershipRole } from "@calcom/prisma/enums";
77
import { MembershipRepository } from "../repository/membership";
88
import { TeamRepository } from "../repository/team";
99

10+
// Type definition for BookingTimeStatusDenormalized view
11+
export type BookingTimeStatusDenormalized = z.infer<typeof bookingDataSchema>;
12+
13+
// Helper type for select parameter
14+
export type BookingSelect = {
15+
[K in keyof BookingTimeStatusDenormalized]?: boolean;
16+
};
17+
18+
// Helper type for selected fields
19+
export type SelectedFields<T> = T extends undefined
20+
? BookingTimeStatusDenormalized
21+
: {
22+
[K in keyof T as T[K] extends true ? K : never]: K extends keyof BookingTimeStatusDenormalized
23+
? BookingTimeStatusDenormalized[K]
24+
: never;
25+
};
26+
27+
export const bookingDataSchema = z
28+
.object({
29+
id: z.number(),
30+
uid: z.string(),
31+
eventTypeId: z.number().nullable(),
32+
title: z.string(),
33+
description: z.string().nullable(),
34+
startTime: z.date(),
35+
endTime: z.date(),
36+
createdAt: z.date(),
37+
updatedAt: z.date().nullable(),
38+
location: z.string().nullable(),
39+
paid: z.boolean(),
40+
status: z.string(), // BookingStatus enum
41+
rescheduled: z.boolean().nullable(),
42+
userId: z.number().nullable(),
43+
teamId: z.number().nullable(),
44+
eventLength: z.number().nullable(),
45+
eventParentId: z.number().nullable(),
46+
userEmail: z.string().nullable(),
47+
userName: z.string().nullable(),
48+
userUsername: z.string().nullable(),
49+
ratingFeedback: z.string().nullable(),
50+
rating: z.number().nullable(),
51+
noShowHost: z.boolean().nullable(),
52+
isTeamBooking: z.boolean(),
53+
timeStatus: z.string().nullable(),
54+
})
55+
.strict();
56+
1057
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
1158
z.object({
1259
scope: z.literal("user"),
@@ -35,17 +82,28 @@ export type InsightsBookingServicePublicOptions = {
3582

3683
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
3784

38-
export type InsightsBookingServiceFilterOptions = {
39-
eventTypeId?: number;
40-
memberUserId?: number;
41-
};
85+
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
86+
87+
export const insightsBookingServiceFilterOptionsSchema = z.object({
88+
eventTypeId: z.number().optional(),
89+
memberUserId: z.number().optional(),
90+
dateRange: z
91+
.object({
92+
target: z.enum(["createdAt", "startTime"]),
93+
startDate: z.string(),
94+
endDate: z.string(),
95+
})
96+
.optional(),
97+
});
4298

4399
const NOTHING_CONDITION = Prisma.sql`1=0`;
44100

101+
const bookingDataKeys = new Set(Object.keys(bookingDataSchema.shape));
102+
45103
export class InsightsBookingService {
46104
private prisma: typeof readonlyPrisma;
47105
private options: InsightsBookingServiceOptions | null;
48-
private filters?: InsightsBookingServiceFilterOptions;
106+
private filters: InsightsBookingServiceFilterOptions | null;
49107
private cachedAuthConditions?: Prisma.Sql;
50108
private cachedFilterConditions?: Prisma.Sql | null;
51109

@@ -59,26 +117,14 @@ export class InsightsBookingService {
59117
filters?: InsightsBookingServiceFilterOptions;
60118
}) {
61119
this.prisma = prisma;
62-
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
63-
this.options = validation.success ? validation.data : null;
120+
const optionsValidated = insightsBookingServiceOptionsSchema.safeParse(options);
121+
this.options = optionsValidated.success ? optionsValidated.data : null;
64122

65-
this.filters = filters;
123+
const filtersValidated = insightsBookingServiceFilterOptionsSchema.safeParse(filters);
124+
this.filters = filtersValidated.success ? filtersValidated.data : null;
66125
}
67126

68-
async getBookingsByHourStats({
69-
startDate,
70-
endDate,
71-
timeZone,
72-
}: {
73-
startDate: string;
74-
endDate: string;
75-
timeZone: string;
76-
}) {
77-
// Validate date formats
78-
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
79-
throw new Error(`Invalid date format: ${startDate} - ${endDate}`);
80-
}
81-
127+
async getBookingsByHourStats({ timeZone }: { timeZone: string }) {
82128
const baseConditions = await this.getBaseConditions();
83129

84130
const results = await this.prisma.$queryRaw<
@@ -92,8 +138,6 @@ export class InsightsBookingService {
92138
COUNT(*)::int as "count"
93139
FROM "BookingTimeStatusDenormalized"
94140
WHERE ${baseConditions}
95-
AND "startTime" >= ${startDate}::timestamp
96-
AND "startTime" <= ${endDate}::timestamp
97141
AND "status" = 'accepted'
98142
GROUP BY 1
99143
ORDER BY 1
@@ -109,6 +153,35 @@ export class InsightsBookingService {
109153
}));
110154
}
111155

156+
async findAll<TSelect extends BookingSelect | undefined = undefined>({
157+
select,
158+
}: {
159+
select?: TSelect;
160+
} = {}): Promise<Array<SelectedFields<TSelect>>> {
161+
const baseConditions = await this.getBaseConditions();
162+
163+
// Build the select clause with validated fields
164+
let selectFields = Prisma.sql`*`;
165+
if (select) {
166+
const keys = Object.keys(select);
167+
if (keys.some((key) => !bookingDataKeys.has(key))) {
168+
throw new Error("Invalid select keys provided");
169+
}
170+
171+
if (keys.length > 0) {
172+
// Use Prisma.sql for each field to ensure proper escaping
173+
const sqlFields = keys.map((field) => Prisma.sql`"${Prisma.raw(field)}"`);
174+
selectFields = Prisma.join(sqlFields, ", ");
175+
}
176+
}
177+
178+
return await this.prisma.$queryRaw<Array<SelectedFields<TSelect>>>`
179+
SELECT ${selectFields}
180+
FROM "BookingTimeStatusDenormalized"
181+
WHERE ${baseConditions}
182+
`;
183+
}
184+
112185
async getBaseConditions(): Promise<Prisma.Sql> {
113186
const authConditions = await this.getAuthorizationConditions();
114187
const filterConditions = await this.getFilterConditions();
@@ -155,6 +228,23 @@ export class InsightsBookingService {
155228
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
156229
}
157230

231+
// Use dateRange object for date filtering
232+
if (this.filters.dateRange) {
233+
const { target, startDate, endDate } = this.filters.dateRange;
234+
if (startDate) {
235+
if (isNaN(Date.parse(startDate))) {
236+
throw new Error(`Invalid date format: ${startDate}`);
237+
}
238+
conditions.push(Prisma.sql`"${Prisma.raw(target)}" >= ${startDate}::timestamp`);
239+
}
240+
if (endDate) {
241+
if (isNaN(Date.parse(endDate))) {
242+
throw new Error(`Invalid date format: ${endDate}`);
243+
}
244+
conditions.push(Prisma.sql`"${Prisma.raw(target)}" <= ${endDate}::timestamp`);
245+
}
246+
}
247+
158248
if (conditions.length === 0) {
159249
return null;
160250
}

0 commit comments

Comments
 (0)