feat: download insights raw data as csv (#11645)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
alannnc
2023-10-16 13:27:25 +02:00
committed by GitHub
co-authored by CarinaWolli
parent 461120ad84
commit fefb6acc57
10 changed files with 428 additions and 2 deletions
+30
View File
@@ -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");
});
});
@@ -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.",
@@ -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,
@@ -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 (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon={FileDownIcon}
color="secondary"
{...(isLoading && { loading: isLoading })}
className="self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { Download };
+5 -1
View File
@@ -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 = () => {
/>
</Tooltip>
</ButtonGroup> */}
<DateSelect />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-nowrap sm:justify-between">
<Download />
<DateSelect />
</div>
</div>
);
};
+231
View File
@@ -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<string, unknown>[]) {
// 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 };
@@ -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<typeof rawDataInputSchema>;
export { rawDataInputSchema };
@@ -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: "" };
}),
});
@@ -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";
+2
View File
@@ -956,6 +956,8 @@ view BookingTimeStatus {
eventLength Int?
timeStatus String?
eventParentId Int?
userEmail String?
username String?
}
model CalendarCache {