feat: download insights raw data as csv (#11645)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>pull/11907/head^2
parent
461120ad84
commit
fefb6acc57
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
@ -956,6 +956,8 @@ view BookingTimeStatus {
|
|||
eventLength Int?
|
||||
timeStatus String?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
username String?
|
||||
}
|
||||
|
||||
model CalendarCache {
|
||||
|
|
Loading…
Reference in New Issue