Skip to content

Commit 6739d2c

Browse files
aryany9mfts
andauthored
Feat: Added feature Export Visits to download the csv of Visits and Views (#840)
* Added feature Export CSV to download the stats of Visits and Views in File Detail page dropdown menu. * Removed abandoned package and converting from json to csv manually. * Removed abandoned package and converting from json to csv manually. * feat: add agreement and dataroom to export data * feat: add plan badge --------- Co-authored-by: Marc Seitz <[email protected]>
1 parent 18d697c commit 6739d2c

File tree

2 files changed

+243
-1
lines changed

2 files changed

+243
-1
lines changed

components/documents/document-header.tsx

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useLimits } from "@/ee/limits/swr-handler";
88
import { Document, DocumentVersion } from "@prisma/client";
99
import {
1010
BetweenHorizontalStartIcon,
11+
FileDownIcon,
1112
PlusIcon,
1213
SheetIcon,
1314
Sparkles,
@@ -38,6 +39,7 @@ import { DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
3839
import { cn, getExtension } from "@/lib/utils";
3940
import { fileIcon } from "@/lib/utils/get-file-icon";
4041

42+
import PlanBadge from "../billing/plan-badge";
4143
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
4244
import { AddDataroomModal } from "../datarooms/add-dataroom-modal";
4345
import { DataroomTrialModal } from "../datarooms/dataroom-trial-modal";
@@ -84,6 +86,7 @@ export default function DocumentHeader({
8486
const numDatarooms = dataRooms?.length ?? 0;
8587
const limitDatarooms = limits?.datarooms ?? 1;
8688

89+
const isFree = plan === "free";
8790
const isBusiness = plan === "business";
8891
const isDatarooms = plan === "datarooms";
8992
const isTrialDatarooms = trial === "drtrial";
@@ -97,6 +100,19 @@ export default function DocumentHeader({
97100
}
98101
}
99102

103+
const currentTime = new Date();
104+
const formattedTime =
105+
currentTime.getFullYear() +
106+
"-" +
107+
String(currentTime.getMonth() + 1).padStart(2, "0") +
108+
"-" +
109+
String(currentTime.getDate()).padStart(2, "0") +
110+
"_" +
111+
String(currentTime.getHours()).padStart(2, "0") +
112+
"-" +
113+
String(currentTime.getMinutes()).padStart(2, "0");
114+
"-" + String(currentTime.getSeconds()).padStart(2, "0");
115+
100116
const plausible = usePlausible();
101117

102118
// https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392
@@ -271,6 +287,76 @@ export default function DocumentHeader({
271287
}
272288
};
273289

290+
// export method to fetch the visits data and convert to csv.
291+
const exportVisitCounts = async (document: Document) => {
292+
if (isFree) {
293+
toast.error("This feature is not available for your plan");
294+
return;
295+
}
296+
try {
297+
const response = await fetch(
298+
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
299+
{ method: "GET" },
300+
);
301+
if (!response.ok) {
302+
throw new Error(`HTTP error! status: ${response.status}`);
303+
}
304+
const data = await response.json();
305+
306+
// Converting the json Array into CSV without using parser.
307+
const csvString = [
308+
[
309+
"Viewed at",
310+
"Name",
311+
"Email",
312+
"Link Name",
313+
"Total Visit Duration (s)",
314+
"Total Document Completion (%)",
315+
"Document version",
316+
"Downloaded at",
317+
"Verified",
318+
"Agreement accepted",
319+
"Viewed from dataroom",
320+
],
321+
...data.visits.map((item: any) => [
322+
item.viewedAt,
323+
item.viewerName,
324+
item.viewerEmail,
325+
item.linkName,
326+
item.totalVisitDuration / 1000.0,
327+
item.visitCompletion,
328+
item.documentVersion,
329+
item.downloadedAt,
330+
item.verified,
331+
item.agreement,
332+
item.dataroom,
333+
]),
334+
]
335+
.map((row) => row.join(","))
336+
.join("\n");
337+
338+
// Creating csv as per the time stamp.
339+
const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" });
340+
const url = URL.createObjectURL(blob);
341+
const link = window.document.createElement("a");
342+
link.href = url;
343+
link.setAttribute(
344+
"download",
345+
`${data.documentName}_visits_${formattedTime}.csv`,
346+
);
347+
window.document.body.appendChild(link);
348+
link.click();
349+
window.document.body.removeChild(link);
350+
URL.revokeObjectURL(url);
351+
toast.success("CSV file downloaded successfully");
352+
} catch (error) {
353+
console.error("Error:", error);
354+
toast.error(
355+
"An error occurred while downloading the CSV. Please try again.",
356+
);
357+
}
358+
};
359+
274360
useEffect(() => {
275361
function handleClickOutside(event: { target: any }) {
276362
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
@@ -574,14 +660,25 @@ export default function DocumentHeader({
574660
)}
575661
{renderDropdownMenuItem()}
576662
<DropdownMenuSeparator />
663+
664+
{/* Export views in CSV */}
665+
<DropdownMenuItem
666+
onClick={() => exportVisitCounts(prismaDocument)}
667+
disabled={isFree}
668+
>
669+
<FileDownIcon className="mr-2 h-4 w-4" />
670+
Export visits {isFree ? <PlanBadge plan="pro" /> : ""}
671+
</DropdownMenuItem>
672+
673+
<DropdownMenuSeparator />
674+
577675
<DropdownMenuItem
578676
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
579677
onClick={(event) => handleButtonClick(event, prismaDocument.id)}
580678
>
581679
<TrashIcon className="mr-2 h-4 w-4" />
582680
{isFirstClick ? "Really delete?" : "Delete document"}
583681
</DropdownMenuItem>
584-
{/* create a dropdownmenuitem that onclick calls a post request to /api/assistants with the documentId */}
585682
</DropdownMenuContent>
586683
</DropdownMenu>
587684
</div>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { NextApiRequest, NextApiResponse } from "next";
2+
3+
import { authOptions } from "@/pages/api/auth/[...nextauth]";
4+
import { getServerSession } from "next-auth/next";
5+
6+
import prisma from "@/lib/prisma";
7+
import { getViewPageDuration } from "@/lib/tinybird";
8+
import { CustomUser } from "@/lib/types";
9+
10+
export default async function handler(
11+
req: NextApiRequest,
12+
res: NextApiResponse,
13+
) {
14+
if (req.method !== "GET") {
15+
// GET /api/teams/:teamId/documents/:id/export-visits
16+
res.setHeader("Allow", ["GET"]);
17+
return res.status(405).end(`Method ${req.method} Not Allowed`);
18+
}
19+
20+
const session = await getServerSession(req, res, authOptions);
21+
if (!session) {
22+
return res.status(401).end("Unauthorized");
23+
}
24+
25+
// get document id and teamId from query params
26+
const { teamId, id: docId } = req.query as { teamId: string; id: string };
27+
28+
const userId = (session.user as CustomUser).id;
29+
30+
try {
31+
// Fetching Team based on team.id
32+
const team = await prisma.team.findUnique({
33+
where: {
34+
id: teamId,
35+
users: {
36+
some: {
37+
userId: userId,
38+
},
39+
},
40+
},
41+
select: { plan: true },
42+
});
43+
44+
if (!team) {
45+
return res.status(404).end("Team not found");
46+
}
47+
48+
if (team.plan.includes("free")) {
49+
return res
50+
.status(403)
51+
.json({ message: "This feature is not available for your plan" });
52+
}
53+
54+
// Fetching Document based on document.id
55+
const document = await prisma.document.findUnique({
56+
where: { id: docId, teamId: teamId },
57+
select: {
58+
id: true,
59+
name: true,
60+
numPages: true,
61+
versions: {
62+
orderBy: { createdAt: "desc" },
63+
select: {
64+
versionNumber: true,
65+
createdAt: true,
66+
numPages: true,
67+
},
68+
},
69+
},
70+
});
71+
72+
if (!document) {
73+
return res.status(404).end("Document not found");
74+
}
75+
76+
// Fetch views data from the database
77+
const views = await prisma.view.findMany({
78+
where: { documentId: docId },
79+
include: {
80+
link: { select: { name: true } },
81+
agreementResponse: true,
82+
},
83+
});
84+
85+
if (!views || views.length === 0) {
86+
return res.status(404).end("Document has no views");
87+
}
88+
89+
// Fetching durations from tinyBird function
90+
const durationsPromises = views?.map((view) =>
91+
getViewPageDuration({
92+
documentId: docId,
93+
viewId: view.id,
94+
since: 0,
95+
}),
96+
);
97+
98+
const durations = await Promise.all(durationsPromises);
99+
100+
const exportData = views?.map((view: any, index: number) => {
101+
// Identifying the document version as per the time we Viewed it.
102+
const relevantDocumentVersion = document.versions.find(
103+
(version) => version.createdAt <= view.viewedAt,
104+
);
105+
106+
const numPages =
107+
relevantDocumentVersion?.numPages || document.numPages || 0;
108+
109+
// Calculating the completion rate in percentage.
110+
const completionRate = numPages
111+
? (durations[index].data.length / numPages) * 100
112+
: 0;
113+
114+
return {
115+
viewedAt: view.viewedAt.toISOString(),
116+
viewerName: view.viewerName || "NaN", // If the value is not available we are showing NaN as per csv
117+
viewerEmail: view.viewerEmail || "NaN",
118+
linkName: view.link?.name || "NaN",
119+
totalVisitDuration: durations[index].data.reduce(
120+
(total, data) => total + data.sum_duration,
121+
0,
122+
),
123+
visitCompletion: completionRate.toFixed(2) + "%",
124+
documentVersion:
125+
relevantDocumentVersion?.versionNumber ||
126+
document.versions[0]?.versionNumber ||
127+
"NaN",
128+
downloadedAt: view.downloadedAt
129+
? view.downloadedAt.toISOString()
130+
: "NaN",
131+
verified: view.verified ? "Yes" : "No",
132+
agreement: view.agreementResponse ? "Yes" : "NaN",
133+
dataroom: view.dataroomId ? "Yes" : "No",
134+
};
135+
});
136+
137+
return res.status(200).json({
138+
documentName: document.name,
139+
visits: exportData,
140+
});
141+
} catch (error) {
142+
console.error(error);
143+
return res.status(500).json({ message: "Something went wrong" });
144+
}
145+
}

0 commit comments

Comments
 (0)