diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx index 7e0ddff59b..91b7ecd32d 100644 --- a/packages/features/insights/filters/Download/Download.tsx +++ b/packages/features/insights/filters/Download/Download.tsx @@ -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 => { 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, }); diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 14354f9a6a..f0bdc114b5 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -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 = {}; - - 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 = {}; - 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 = {}; - 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, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index b2b4105266..e9c5443b79 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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() })) diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index c52c6a4e2a..7adb0199c9 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -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 = {}; + + 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 = {}; + 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 = {}; + 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 { // Check if the user is an owner or admin of the organization const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });