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

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
pull/11907/head^2
alannnc 2023-10-16 04:27:25 -07:00 committed by GitHub
parent 461120ad84
commit fefb6acc57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 428 additions and 2 deletions

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");
});
});

View File

@ -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.",

View File

@ -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,

View File

@ -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 };

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>
);
};

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 };

View File

@ -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 };

View File

@ -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: "" };
}),
});

View File

@ -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";

View File

@ -956,6 +956,8 @@ view BookingTimeStatus {
eventLength Int?
timeStatus String?
eventParentId Int?
userEmail String?
username String?
}
model CalendarCache {