fix: use InsightsBookingService for Download button on /insights (#22698)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user