Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
134 changes: 67 additions & 67 deletions packages/features/insights/server/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { totalDuration: number; count: number }>();

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<string, { totalDuration: number; count: number }>();
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 }) => {
Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 114 additions & 24 deletions packages/lib/server/service/insightsBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;

Expand All @@ -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<
Expand All @@ -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'
Comment on lines 139 to 141
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this startTime check has been moved into filters. The following is added on the trpc-router.ts file.

          dateRange: {
            target: "startTime",
            startDate,
            endDate,
          },

GROUP BY 1
ORDER BY 1
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
Loading