Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion components/documents/document-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useLimits } from "@/ee/limits/swr-handler";
import { Document, DocumentVersion } from "@prisma/client";
import {
BetweenHorizontalStartIcon,
FileDownIcon,
PlusIcon,
SheetIcon,
Sparkles,
Expand Down Expand Up @@ -38,6 +39,7 @@ import { DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { cn, getExtension } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";

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

const isFree = plan === "free";
const isBusiness = plan === "business";
const isDatarooms = plan === "datarooms";
const isTrialDatarooms = trial === "drtrial";
Expand All @@ -97,6 +100,19 @@ export default function DocumentHeader({
}
}

const currentTime = new Date();
const formattedTime =
currentTime.getFullYear() +
"-" +
String(currentTime.getMonth() + 1).padStart(2, "0") +
"-" +
String(currentTime.getDate()).padStart(2, "0") +
"_" +
String(currentTime.getHours()).padStart(2, "0") +
"-" +
String(currentTime.getMinutes()).padStart(2, "0");
"-" + String(currentTime.getSeconds()).padStart(2, "0");

const plausible = usePlausible();

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

// export method to fetch the visits data and convert to csv.
const exportVisitCounts = async (document: Document) => {
if (isFree) {
toast.error("This feature is not available for your plan");
return;
}
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
{ method: "GET" },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();

// Converting the json Array into CSV without using parser.
const csvString = [
[
"Viewed at",
"Name",
"Email",
"Link Name",
"Total Visit Duration (s)",
"Total Document Completion (%)",
"Document version",
"Downloaded at",
"Verified",
"Agreement accepted",
"Viewed from dataroom",
],
...data.visits.map((item: any) => [
item.viewedAt,
item.viewerName,
item.viewerEmail,
item.linkName,
item.totalVisitDuration / 1000.0,
item.visitCompletion,
item.documentVersion,
item.downloadedAt,
item.verified,
item.agreement,
item.dataroom,
]),
]
.map((row) => row.join(","))
.join("\n");

// Creating csv as per the time stamp.
const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.setAttribute(
"download",
`${data.documentName}_visits_${formattedTime}.csv`,
);
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("CSV file downloaded successfully");
} catch (error) {
console.error("Error:", error);
toast.error(
"An error occurred while downloading the CSV. Please try again.",
);
}
};

useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
Expand Down Expand Up @@ -574,14 +660,25 @@ export default function DocumentHeader({
)}
{renderDropdownMenuItem()}
<DropdownMenuSeparator />

{/* Export views in CSV */}
<DropdownMenuItem
onClick={() => exportVisitCounts(prismaDocument)}
disabled={isFree}
>
<FileDownIcon className="mr-2 h-4 w-4" />
Export visits {isFree ? <PlanBadge plan="pro" /> : ""}
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuItem
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={(event) => handleButtonClick(event, prismaDocument.id)}
>
<TrashIcon className="mr-2 h-4 w-4" />
{isFirstClick ? "Really delete?" : "Delete document"}
</DropdownMenuItem>
{/* create a dropdownmenuitem that onclick calls a post request to /api/assistants with the documentId */}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
145 changes: 145 additions & 0 deletions pages/api/teams/[teamId]/documents/[id]/export-visits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { NextApiRequest, NextApiResponse } from "next";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";

import prisma from "@/lib/prisma";
import { getViewPageDuration } from "@/lib/tinybird";
import { CustomUser } from "@/lib/types";

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
// GET /api/teams/:teamId/documents/:id/export-visits
res.setHeader("Allow", ["GET"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}

const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}

// get document id and teamId from query params
const { teamId, id: docId } = req.query as { teamId: string; id: string };

const userId = (session.user as CustomUser).id;

try {
// Fetching Team based on team.id
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: { plan: true },
});

if (!team) {
return res.status(404).end("Team not found");
}

if (team.plan.includes("free")) {
return res
.status(403)
.json({ message: "This feature is not available for your plan" });
}

// Fetching Document based on document.id
const document = await prisma.document.findUnique({
where: { id: docId, teamId: teamId },
select: {
id: true,
name: true,
numPages: true,
versions: {
orderBy: { createdAt: "desc" },
select: {
versionNumber: true,
createdAt: true,
numPages: true,
},
},
},
});

if (!document) {
return res.status(404).end("Document not found");
}

// Fetch views data from the database
const views = await prisma.view.findMany({
where: { documentId: docId },
include: {
link: { select: { name: true } },
agreementResponse: true,
},
});

if (!views || views.length === 0) {
return res.status(404).end("Document has no views");
}

// Fetching durations from tinyBird function
const durationsPromises = views?.map((view) =>
getViewPageDuration({
documentId: docId,
viewId: view.id,
since: 0,
}),
);

const durations = await Promise.all(durationsPromises);

const exportData = views?.map((view: any, index: number) => {
// Identifying the document version as per the time we Viewed it.
const relevantDocumentVersion = document.versions.find(
(version) => version.createdAt <= view.viewedAt,
);

const numPages =
relevantDocumentVersion?.numPages || document.numPages || 0;

// Calculating the completion rate in percentage.
const completionRate = numPages
? (durations[index].data.length / numPages) * 100
: 0;

return {
viewedAt: view.viewedAt.toISOString(),
viewerName: view.viewerName || "NaN", // If the value is not available we are showing NaN as per csv
viewerEmail: view.viewerEmail || "NaN",
linkName: view.link?.name || "NaN",
totalVisitDuration: durations[index].data.reduce(
(total, data) => total + data.sum_duration,
0,
),
visitCompletion: completionRate.toFixed(2) + "%",
documentVersion:
relevantDocumentVersion?.versionNumber ||
document.versions[0]?.versionNumber ||
"NaN",
downloadedAt: view.downloadedAt
? view.downloadedAt.toISOString()
: "NaN",
verified: view.verified ? "Yes" : "No",
agreement: view.agreementResponse ? "Yes" : "NaN",
dataroom: view.dataroomId ? "Yes" : "No",
};
});

return res.status(200).json({
documentName: document.name,
visits: exportData,
});
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
}
}