fix: use InsightsBookingService for Download button on /insights (#22698)

This commit is contained in:
Eunjae Lee
2025-07-25 10:27:13 +01:00
committed by GitHub
parent cd50719b03
commit f901fba0fc
4 changed files with 231 additions and 333 deletions
@@ -1,8 +1,10 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { useDataTable } from "@calcom/features/data-table";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
@@ -22,7 +24,8 @@ const BATCH_SIZE = 100;
const Download = () => {
const { t } = useLocale();
const { startDate, endDate, teamId, userId, eventTypeId, memberUserId, isAll } = useInsightsParameters();
const { timeZone } = useDataTable();
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId } = useInsightsParameters();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
@@ -34,13 +37,13 @@ const Download = () => {
const fetchBatch = async (offset: number): Promise<PaginatedResponse | null> => {
try {
const result = await utils.viewer.insights.rawData.fetch({
scope,
selectedTeamId,
startDate,
endDate,
teamId,
userId,
timeZone: timeZone || CURRENT_TIMEZONE,
eventTypeId,
memberUserId,
isAll,
limit: BATCH_SIZE,
offset,
});
-294
View File
@@ -3,8 +3,6 @@ import dayjs from "@calcom/dayjs";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { RawDataInput } from "./raw-data.schema";
type TimeViewType = "week" | "month" | "year" | "day";
type StatusAggregate = {
@@ -456,298 +454,6 @@ class EventsInsights {
}
}
static getCsvData = async (
props: RawDataInput & {
organizationId: number | null;
isOrgAdminOrOwner: boolean | null;
}
) => {
// Obtain the where conditional
const whereConditional = await this.obtainWhereConditionalForDownload(props);
const limit = props.limit ?? 100; // Default batch size
const offset = props.offset ?? 0;
const totalCountPromise = prisma.bookingTimeStatusDenormalized.count({
where: whereConditional,
});
const csvDataPromise = prisma.bookingTimeStatusDenormalized.findMany({
select: {
id: true,
uid: true,
title: true,
createdAt: true,
timeStatus: true,
eventTypeId: true,
eventLength: true,
startTime: true,
endTime: true,
paid: true,
userEmail: true,
userUsername: true,
rating: true,
ratingFeedback: true,
noShowHost: true,
},
where: whereConditional,
skip: offset,
take: limit,
});
const [totalCount, csvData] = await Promise.all([totalCountPromise, csvDataPromise]);
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
if (uids.length === 0) {
return { data: csvData, total: totalCount };
}
const bookings = await prisma.booking.findMany({
where: {
uid: {
in: uids,
},
},
select: {
uid: true,
attendees: {
select: {
name: true,
email: true,
noShow: true,
},
},
seatsReferences: {
select: {
attendee: {
select: {
name: true,
email: true,
noShow: true,
},
},
},
},
},
});
const bookingMap = new Map(
bookings.map((booking) => {
const attendeeList =
booking.seatsReferences.length > 0
? booking.seatsReferences.map((ref) => ref.attendee)
: booking.attendees;
// List all no-show guests (name and email)
const noShowGuests =
attendeeList
.filter((attendee) => attendee?.noShow)
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean) // remove null values
.join("; ") || null;
const noShowGuestsCount = attendeeList.filter((attendee) => attendee?.noShow).length;
const formattedAttendees = attendeeList
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean);
return [booking.uid, { attendeeList: formattedAttendees, noShowGuests, noShowGuestsCount }];
})
);
const maxAttendees = Math.max(
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
0
);
const finalBookingMap = new Map(
Array.from(bookingMap.entries()).map(([uid, data]) => {
const attendeeFields: Record<string, string | null> = {};
for (let i = 1; i <= maxAttendees; i++) {
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
}
return [
uid,
{
noShowGuests: data.noShowGuests,
noShowGuestsCount: data.noShowGuestsCount,
...attendeeFields,
},
];
})
);
const data = csvData.map((bookingTimeStatus) => {
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuests: null,
...nullAttendeeFields,
};
}
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
if (!attendeeData) {
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuests: null,
...nullAttendeeFields,
};
}
return {
...bookingTimeStatus,
noShowGuests: attendeeData.noShowGuests,
noShowGuestsCount: attendeeData.noShowGuestsCount,
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
};
});
return { data, total: totalCount };
};
/*
* This is meant to be used for all functions inside insights router,
* but it's currently used only for CSV download.
* Ideally we should have a view that have all of this data
* The order where will be from the most specific to the least specific
* starting from the top will be:
* - memberUserId
* - eventTypeId
* - userId
* - teamId
* Generics will be:
* - isAll
* - startDate
* - endDate
* @param props
* @returns
*/
static obtainWhereConditionalForDownload = async (
props: RawDataInput & { organizationId: number | null; isOrgAdminOrOwner: boolean | null }
) => {
const {
startDate,
endDate,
teamId,
userId,
memberUserId,
isAll,
eventTypeId,
organizationId,
isOrgAdminOrOwner,
} = props;
// Obtain the where conditional
let whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {};
if (startDate && endDate) {
whereConditional.createdAt = {
gte: dayjs(startDate).toISOString(),
lte: dayjs(endDate).toISOString(),
};
}
if (eventTypeId) {
whereConditional["eventTypeId"] = eventTypeId;
}
if (memberUserId) {
whereConditional["userId"] = memberUserId;
}
if (userId) {
whereConditional["teamId"] = null;
whereConditional["userId"] = userId;
}
if (isAll && isOrgAdminOrOwner && organizationId) {
const teamsFromOrg = await prisma.team.findMany({
where: {
parentId: organizationId,
},
select: {
id: true,
},
});
if (teamsFromOrg.length === 0) {
return {};
}
const teamIds: number[] = [organizationId, ...teamsFromOrg.map((t) => t.id)];
const usersFromOrg = await prisma.membership.findMany({
where: {
teamId: {
in: teamIds,
},
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
whereConditional = {
...whereConditional,
OR: [
{
userId: {
in: userIdsFromOrg,
},
isTeamBooking: false,
},
{
teamId: {
in: teamIds,
},
isTeamBooking: true,
},
],
};
}
if (teamId && !isAll) {
const usersFromTeam = await prisma.membership.findMany({
where: {
teamId: teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
whereConditional = {
...whereConditional,
OR: [
{
teamId,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeam,
},
isTeamBooking: false,
},
],
};
}
return whereConditional;
};
static userIsOwnerAdminOfTeam = async ({
sessionUserId,
teamId,
@@ -1496,43 +1496,44 @@ export const insightsRouter = router({
return result;
}),
rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const { startDate, endDate, teamId, userId, memberUserId, isAll, limit, offset } = input;
rawData: userBelongsToTeamProcedure
.input(
bookingRepositoryBaseInputSchema.extend({
limit: z.number().max(100).optional(),
offset: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, limit, offset } = input;
const eventTypeList = await getEventTypeList({
prisma: ctx.prisma,
teamId,
userId,
isAll,
user: {
id: ctx.user.id,
organizationId: ctx.user.organizationId,
isOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
},
});
// use eventTypeId filter only if it's accessible by this user
const eventTypeId = eventTypeList.find((eventType) => eventType.id === input.eventTypeId)?.id;
const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam;
try {
return await EventsInsights.getCsvData({
startDate,
endDate,
teamId,
userId,
memberUserId,
isAll,
isOrgAdminOrOwner,
eventTypeId,
organizationId: ctx.user.organizationId || null,
limit,
offset,
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,
},
},
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
try {
return await insightsBookingService.getCsvData({
limit: limit ?? 100,
offset: offset ?? 0,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getRoutingFormsForFilters: userBelongsToTeamProcedure
.input(z.object({ userId: z.number().optional(), teamId: z.number().optional(), isAll: z.boolean() }))
@@ -341,6 +341,194 @@ export class InsightsBookingService {
});
}
async getCsvData({ limit = 100, offset = 0 }: { limit?: number; offset?: number }) {
const baseConditions = await this.getBaseConditions();
// Get total count first
const totalCountResult = await this.prisma.$queryRaw<[{ count: number }]>`
SELECT COUNT(*)::int as count
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
`;
const totalCount = totalCountResult[0]?.count || 0;
// 1. Get booking data from BookingTimeStatusDenormalized
const csvData = await this.prisma.$queryRaw<
Array<{
id: number;
uid: string | null;
title: string;
createdAt: Date;
timeStatus: string;
eventTypeId: number | null;
eventLength: number;
startTime: Date;
endTime: Date;
paid: boolean;
userEmail: string;
userUsername: string;
rating: number | null;
ratingFeedback: string | null;
noShowHost: boolean;
}>
>`
SELECT
"id",
"uid",
"title",
"createdAt",
"timeStatus",
"eventTypeId",
"eventLength",
"startTime",
"endTime",
"paid",
"userEmail",
"userUsername",
"rating",
"ratingFeedback",
"noShowHost"
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
ORDER BY "createdAt" DESC
LIMIT ${limit}
OFFSET ${offset}
`;
if (csvData.length === 0) {
return { data: csvData, total: totalCount };
}
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
if (uids.length === 0) {
return { data: csvData, total: totalCount };
}
// 2. Get all bookings with their attendees and seat references
const bookings = await this.prisma.booking.findMany({
where: {
uid: {
in: uids,
},
},
select: {
uid: true,
attendees: {
select: {
name: true,
email: true,
noShow: true,
},
},
seatsReferences: {
select: {
attendee: {
select: {
name: true,
email: true,
noShow: true,
},
},
},
},
},
});
// 3. Create booking map with attendee data (matching original logic)
const bookingMap = new Map(
bookings.map((booking) => {
const attendeeList =
booking.seatsReferences.length > 0
? booking.seatsReferences.map((ref) => ref.attendee)
: booking.attendees;
// List all no-show guests (name and email)
const noShowGuests =
attendeeList
.filter((attendee) => attendee?.noShow)
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean) // remove null values
.join("; ") || null;
const noShowGuestsCount = attendeeList.filter((attendee) => attendee?.noShow).length;
const formattedAttendees = attendeeList
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
.filter(Boolean);
return [booking.uid, { attendeeList: formattedAttendees, noShowGuests, noShowGuestsCount }];
})
);
// 4. Calculate max attendees for dynamic columns
const maxAttendees = Math.max(
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
0
);
// 5. Create final booking map with attendee fields
const finalBookingMap = new Map(
Array.from(bookingMap.entries()).map(([uid, data]) => {
const attendeeFields: Record<string, string | null> = {};
for (let i = 1; i <= maxAttendees; i++) {
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
}
return [
uid,
{
noShowGuests: data.noShowGuests,
noShowGuestsCount: data.noShowGuestsCount,
...attendeeFields,
},
];
})
);
// 6. Combine booking data with attendee data
const data = csvData.map((bookingTimeStatus) => {
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuests: null,
noShowGuestsCount: 0,
...nullAttendeeFields,
};
}
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
if (!attendeeData) {
const nullAttendeeFields: Record<string, null> = {};
for (let i = 1; i <= maxAttendees; i++) {
nullAttendeeFields[`attendee${i}`] = null;
}
return {
...bookingTimeStatus,
noShowGuests: null,
noShowGuestsCount: 0,
...nullAttendeeFields,
};
}
return {
...bookingTimeStatus,
noShowGuests: attendeeData.noShowGuests,
noShowGuestsCount: attendeeData.noShowGuestsCount,
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
};
});
return { data, total: totalCount };
}
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
// Check if the user is an owner or admin of the organization
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });