From fefb6acc57251ff5fca0a189a305c5697724d279 Mon Sep 17 00:00:00 2001 From: alannnc Date: Mon, 16 Oct 2023 04:27:25 -0700 Subject: [PATCH] feat: download insights raw data as csv (#11645) Co-authored-by: CarinaWolli --- apps/web/playwright/insights.e2e.ts | 30 +++ apps/web/public/static/locales/en/common.json | 1 + .../insights/context/FiltersProvider.tsx | 4 +- .../insights/filters/Download/index.tsx | 75 ++++++ packages/features/insights/filters/index.tsx | 6 +- packages/features/insights/server/events.ts | 231 ++++++++++++++++++ .../insights/server/raw-data.schema.ts | 15 ++ .../features/insights/server/trpc-router.ts | 31 +++ .../migration.sql | 35 +++ packages/prisma/schema.prisma | 2 + 10 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 packages/features/insights/filters/Download/index.tsx create mode 100644 packages/features/insights/server/raw-data.schema.ts create mode 100644 packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index bcf0a71b53..0a3d996763 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -236,4 +236,34 @@ test.describe("Insights", async () => { // expect for "Team: test-insight" text in page expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy(); }); + + test("should test download button", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + await page.waitForLoadState("networkidle"); + + const downloadPromise = page.waitForEvent("download"); + + // Expect download button to be visible + expect(await page.locator("text=Download").isVisible()).toBeTruthy(); + + // Click on Download button + await page.getByText("Download").click(); + + // Expect as csv option to be visible + expect(await page.locator("text=as CSV").isVisible()).toBeTruthy(); + + // Start waiting for download before clicking. Note no await. + await page.getByText("as CSV").click(); + const download = await downloadPromise; + + // Wait for the download process to complete and save the downloaded file somewhere. + await download.saveAs("./" + "test-insights.csv"); + }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 2cdf3246e4..8d938333f3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2091,6 +2091,7 @@ "copy_client_secret_info": "After copying the secret you won't be able to view it anymore", "add_new_client": "Add new Client", "this_app_is_not_setup_already": "This app has not been setup yet", + "as_csv": "as CSV", "overlay_my_calendar":"Overlay my calendar", "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx index c23430c19e..dcebb2a777 100644 --- a/packages/features/insights/context/FiltersProvider.tsx +++ b/packages/features/insights/context/FiltersProvider.tsx @@ -3,12 +3,14 @@ import { useState } from "react"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import { trpc } from "@calcom/trpc"; import type { FilterContextType } from "./provider"; import { FilterProvider } from "./provider"; export function FiltersProvider({ children }: { children: React.ReactNode }) { // searchParams to get initial values from query params + const utils = trpc.useContext(); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -105,7 +107,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) { ...configFilters, ...newConfigFilters, }); - + utils.viewer.insights.rawData.invalidate(); const { selectedMemberUserId, selectedTeamId, diff --git a/packages/features/insights/filters/Download/index.tsx b/packages/features/insights/filters/Download/index.tsx new file mode 100644 index 0000000000..73d49bfaa4 --- /dev/null +++ b/packages/features/insights/filters/Download/index.tsx @@ -0,0 +1,75 @@ +import { FileDownIcon } from "lucide-react"; + +import { useFilterContext } from "@calcom/features/insights/context/provider"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc"; +import { trpc } from "@calcom/trpc"; +import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger, Button } from "@calcom/ui"; + +const Download = () => { + const { filter } = useFilterContext(); + + const { t } = useLocale(); + + const { data, isLoading } = trpc.viewer.insights.rawData.useQuery( + { + startDate: filter.dateRange[0].toISOString(), + endDate: filter.dateRange[1].toISOString(), + teamId: filter.selectedTeamId, + userId: filter.selectedUserId, + eventTypeId: filter.selectedEventTypeId, + memberUserId: filter.selectedMemberUserId, + }, + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + staleTime: Infinity, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined; + const handleDownloadClick = async (data: RawData) => { + if (!data) return; + const { data: csvRaw, filename } = data; + + // Create a Blob from the text data + const blob = new Blob([csvRaw], { type: "text/plain" }); + + // Create an Object URL for the Blob + const url = window.URL.createObjectURL(blob); + + // Create a download link + const a = document.createElement("a"); + a.href = url; + a.download = filename; // Specify the filename + + // Simulate a click event to trigger the download + a.click(); + + // Release the Object URL to free up memory + window.URL.revokeObjectURL(url); + }; + + return ( + + + + + + handleDownloadClick(data)}>{t("as_csv")} + + + ); +}; + +export { Download }; diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index 6c11839a8c..cc5ab30a8b 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -4,6 +4,7 @@ import { Button, Tooltip } from "@calcom/ui"; import { X } from "@calcom/ui/components/icon"; import { DateSelect } from "./DateSelect"; +import { Download } from "./Download/index"; import { EventTypeList } from "./EventTypeList"; import { FilterType } from "./FilterType"; import { TeamAndSelfList } from "./TeamAndSelfList"; @@ -72,7 +73,10 @@ export const Filters = () => { /> */} - +
+ + +
); }; diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 240d4b936a..44e9884579 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -3,6 +3,8 @@ import dayjs from "@calcom/dayjs"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; +import type { RawDataInput } from "./raw-data.schema"; + interface ITimeRange { start: Dayjs; end: Dayjs; @@ -213,6 +215,235 @@ class EventsInsights { } return result; }; + + static getCsvData = async ( + props: RawDataInput & { + organizationId: number | null; + isOrgAdminOrOwner: boolean | null; + } + ) => { + // Obtain the where conditional + const whereConditional = await this.obtainWhereConditional(props); + + const csvData = await prisma.bookingTimeStatus.findMany({ + select: { + id: true, + uid: true, + title: true, + createdAt: true, + timeStatus: true, + eventTypeId: true, + eventLength: true, + startTime: true, + endTime: true, + paid: true, + userEmail: true, + username: true, + }, + where: whereConditional, + }); + + return csvData; + }; + + /* + * This is meant to be used for all functions inside insights router, 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 obtainWhereConditional = 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.BookingTimeStatusWhereInput = {}; + let teamConditional: Prisma.TeamWhereInput = {}; + + if (startDate && endDate) { + whereConditional.createdAt = { + gte: dayjs(startDate).toISOString(), + lte: dayjs(endDate).toISOString(), + }; + } + + if (eventTypeId) { + whereConditional["OR"] = [ + { + eventTypeId, + }, + { + eventParentId: 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 {}; + } + teamConditional = { + id: { + in: [organizationId, ...teamsFromOrg.map((t) => t.id)], + }, + }; + const usersFromOrg = await prisma.membership.findMany({ + where: { + team: teamConditional, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromOrg = usersFromOrg.map((u) => u.userId); + whereConditional = { + ...whereConditional, + OR: [ + { + userId: { + in: userIdsFromOrg, + }, + teamId: null, + }, + { + teamId: { + in: [organizationId, ...teamsFromOrg.map((t) => t.id)], + }, + }, + ], + }; + } + + 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, + }, + { + userId: { + in: userIdsFromTeam, + }, + teamId: null, + }, + ], + }; + } + + return whereConditional; + }; + + static userIsOwnerAdminOfTeam = async ({ + sessionUserId, + teamId, + }: { + sessionUserId: number; + teamId: number; + }) => { + const isOwnerAdminOfTeam = await prisma.membership.findFirst({ + where: { + userId: sessionUserId, + teamId, + accepted: true, + role: { + in: ["OWNER", "ADMIN"], + }, + }, + }); + + return !!isOwnerAdminOfTeam; + }; + + static userIsOwnerAdminOfParentTeam = async ({ + sessionUserId, + teamId, + }: { + sessionUserId: number; + teamId: number; + }) => { + const team = await prisma.team.findFirst({ + select: { + parentId: true, + }, + where: { + id: teamId, + }, + }); + + if (!team || team.parentId === null) { + return false; + } + + const isOwnerAdminOfParentTeam = await prisma.membership.findFirst({ + where: { + userId: sessionUserId, + teamId: team.parentId, + accepted: true, + role: { + in: ["OWNER", "ADMIN"], + }, + }, + }); + + return !!isOwnerAdminOfParentTeam; + }; + + static objectToCsv(data: Record[]) { + // if empty data return empty string + if (!data.length) { + return ""; + } + const header = Object.keys(data[0]).join(",") + "\n"; + const rows = data.map((obj: any) => Object.values(obj).join(",") + "\n"); + return header + rows.join(""); + } } export { EventsInsights }; diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts new file mode 100644 index 0000000000..8f63073da8 --- /dev/null +++ b/packages/features/insights/server/raw-data.schema.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +const rawDataInputSchema = z.object({ + startDate: z.string(), + endDate: z.string(), + teamId: z.coerce.number().optional().nullable(), + userId: z.coerce.number().optional().nullable(), + memberUserId: z.coerce.number().optional().nullable(), + isAll: z.coerce.boolean().optional(), + eventTypeId: z.coerce.number().optional().nullable(), +}); + +export type RawDataInput = z.infer; + +export { rawDataInputSchema }; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 00444f9dcd..d172c4e94a 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -3,6 +3,8 @@ import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema"; +import { randomString } from "@calcom/lib/random"; import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; import { router } from "@calcom/trpc/server/trpc"; @@ -1415,4 +1417,33 @@ export const insightsRouter = router({ return eventTypeResult; }), + rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { + const { startDate, endDate, teamId, userId, memberUserId, isAll, eventTypeId } = input; + + const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam; + try { + // Get the data + const csvData = await EventsInsights.getCsvData({ + startDate, + endDate, + teamId, + userId, + memberUserId, + isAll, + isOrgAdminOrOwner, + eventTypeId, + organizationId: ctx.user.organizationId || null, + }); + + const csvAsString = EventsInsights.objectToCsv(csvData); + const downloadAs = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format( + "YYYY-MM-DD" + )}-${randomString(10)}.csv`; + + return { data: csvAsString, filename: downloadAs }; + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + return { data: "", filename: "" }; + }), }); diff --git a/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql b/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql new file mode 100644 index 0000000000..38a68f839a --- /dev/null +++ b/packages/prisma/migrations/20231001101010_add_user_email_to_booking_time_status/migration.sql @@ -0,0 +1,35 @@ +-- View: public.BookingTimeStatus + +-- DROP VIEW public."BookingTimeStatus"; + +CREATE OR REPLACE VIEW public."BookingTimeStatus" + AS + SELECT "Booking".id, + "Booking".uid, + "Booking"."eventTypeId", + "Booking".title, + "Booking".description, + "Booking"."startTime", + "Booking"."endTime", + "Booking"."createdAt", + "Booking".location, + "Booking".paid, + "Booking".status, + "Booking".rescheduled, + "Booking"."userId", + et."teamId", + et.length AS "eventLength", + CASE + WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text + WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text + WHEN "Booking"."endTime" < now() THEN 'completed'::text + WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text + ELSE NULL::text + END AS "timeStatus", + et."parentId" AS "eventParentId", + "u"."email" AS "userEmail", + "u"."username" AS "username" + FROM "Booking" + LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id + LEFT JOIN users u ON u.id = "Booking"."userId"; + diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 412689ee63..009bcee6d8 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -956,6 +956,8 @@ view BookingTimeStatus { eventLength Int? timeStatus String? eventParentId Int? + userEmail String? + username String? } model CalendarCache {