fix: refactor AverageEventDurationChart to use InsightsBookingService (#22702)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user