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 {