diff --git a/app/api/views-dataroom/route.ts b/app/api/views-dataroom/route.ts
index 633af43a5..b9c1a4e69 100644
--- a/app/api/views-dataroom/route.ts
+++ b/app/api/views-dataroom/route.ts
@@ -120,6 +120,18 @@ export async function POST(request: NextRequest) {
allowDownload: true,
enableConversation: true,
teamId: true,
+ document: {
+ select: {
+ versions: {
+ where: {
+ id: documentVersionId,
+ },
+ select: {
+ file: true,
+ },
+ },
+ },
+ },
team: {
select: {
plan: true,
@@ -821,6 +833,11 @@ export async function POST(request: NextRequest) {
type: documentVersion.storageType,
});
}
+ if (documentVersion.type === "link") {
+ if (link.document && link.document.versions.length > 0) {
+ documentVersion.file = link.document.versions[0].file;
+ }
+ }
if (documentVersion.type === "sheet") {
const document = await prisma.document.findUnique({
where: { id: documentId },
@@ -917,7 +934,8 @@ export async function POST(request: NextRequest) {
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "zip" ||
- documentVersion.type === "video")) ||
+ documentVersion.type === "video" ||
+ documentVersion.type === "link")) ||
(documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
diff --git a/app/api/views/route.ts b/app/api/views/route.ts
index 2ae77def6..12e6b91b4 100644
--- a/app/api/views/route.ts
+++ b/app/api/views/route.ts
@@ -98,6 +98,18 @@ export async function POST(request: NextRequest) {
enableWatermark: true,
watermarkConfig: true,
teamId: true,
+ document: {
+ select: {
+ versions: {
+ where: {
+ id: documentVersionId,
+ },
+ select: {
+ file: true,
+ },
+ },
+ },
+ },
team: {
select: {
plan: true,
@@ -498,24 +510,24 @@ export async function POST(request: NextRequest) {
...(link.enableAgreement &&
link.agreementId &&
hasConfirmedAgreement && {
- agreementResponse: {
- create: {
- agreementId: link.agreementId,
- },
+ agreementResponse: {
+ create: {
+ agreementId: link.agreementId,
},
- }),
+ },
+ }),
...(customFields &&
link.customFields.length > 0 && {
- customFieldResponse: {
- create: {
- data: link.customFields.map((field) => ({
- identifier: field.identifier,
- label: field.label,
- response: customFields[field.identifier] || "",
- })),
- },
+ customFieldResponse: {
+ create: {
+ data: link.customFields.map((field) => ({
+ identifier: field.identifier,
+ label: field.label,
+ response: customFields[field.identifier] || "",
+ })),
},
- }),
+ },
+ }),
},
select: { id: true },
});
@@ -590,6 +602,14 @@ export async function POST(request: NextRequest) {
});
}
+ if (documentVersion.type === "link") {
+ if (link.document?.versions && link.document.versions.length > 0) {
+ documentVersion.file = link.document.versions[0].file;
+ } else {
+ throw new Error("Link document version not found.");
+ }
+ }
+
if (documentVersion.type === "sheet") {
if (useAdvancedExcelViewer) {
if (!documentVersion.file.includes("https://")) {
@@ -637,15 +657,16 @@ export async function POST(request: NextRequest) {
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "zip" ||
- documentVersion.type === "video")) ||
- (documentVersion && useAdvancedExcelViewer)
+ documentVersion.type === "video" ||
+ documentVersion.type === "link")) ||
+ (documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
pages: documentPages ? documentPages : undefined,
sheetData:
documentVersion &&
- documentVersion.type === "sheet" &&
- !useAdvancedExcelViewer
+ documentVersion.type === "sheet" &&
+ !useAdvancedExcelViewer
? sheetData
: undefined,
fileType: documentVersion
@@ -658,10 +679,10 @@ export async function POST(request: NextRequest) {
: undefined,
ipAddress:
link.enableWatermark &&
- link.watermarkConfig &&
- WatermarkConfigSchema.parse(link.watermarkConfig).text.includes(
- "{{ipAddress}}",
- )
+ link.watermarkConfig &&
+ WatermarkConfigSchema.parse(link.watermarkConfig).text.includes(
+ "{{ipAddress}}",
+ )
? process.env.VERCEL === "1"
? ipAddress(request)
: LOCALHOST_IP
diff --git a/components/analytics/links-table.tsx b/components/analytics/links-table.tsx
index c6448de47..e895ad034 100644
--- a/components/analytics/links-table.tsx
+++ b/components/analytics/links-table.tsx
@@ -22,11 +22,16 @@ import {
ChevronsUpDownIcon,
Copy,
Download,
- Link2Icon,
+ LinkIcon,
} from "lucide-react";
import { toast } from "sonner";
import useSWR from "swr";
+import { usePlan } from "@/lib/swr/use-billing";
+import { cn, timeAgo } from "@/lib/utils";
+import { fetcher } from "@/lib/utils";
+import { downloadCSV } from "@/lib/utils/csv";
+
import { Button } from "@/components/ui/button";
import {
Table,
@@ -38,11 +43,6 @@ import {
} from "@/components/ui/table";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
-import { usePlan } from "@/lib/swr/use-billing";
-import { cn, timeAgo } from "@/lib/utils";
-import { fetcher } from "@/lib/utils";
-import { downloadCSV } from "@/lib/utils/csv";
-
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Link {
@@ -315,7 +315,7 @@ export default function LinksTable({
-
+
{table.getHeaderGroups().map((headerGroup) => (
@@ -365,7 +365,7 @@ export default function LinksTable({
diff --git a/components/documents/add-document-modal.tsx b/components/documents/add-document-modal.tsx
index 873138818..7d3ab2d06 100644
--- a/components/documents/add-document-modal.tsx
+++ b/components/documents/add-document-modal.tsx
@@ -72,6 +72,7 @@ export function AddDocumentModal({
const [isOpen, setIsOpen] = useState(undefined);
const [currentFile, setCurrentFile] = useState(null);
const [notionLink, setNotionLink] = useState(null);
+ const [linkUrl, setLinkUrl] = useState(null);
const [showGroupPermissions, setShowGroupPermissions] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<
{
@@ -503,9 +504,139 @@ export function AddDocumentModal({
}
};
+ const createLinkFileName = (url: string) => {
+ try {
+ const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
+ const domain = urlObj.hostname.replace("www.", "");
+ return `${domain} - Link`;
+ } catch {
+ return "External Link";
+ }
+ };
+
+ const handleLinkUpload = async (
+ event: FormEvent,
+ ): Promise => {
+ event.preventDefault();
+
+ if (!canAddDocuments) {
+ toast.error("You have reached the maximum number of documents.");
+ return;
+ }
+ const isValidURL =
+ /^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/;
+ if (!linkUrl) {
+ toast.error("Please enter a link to proceed.");
+ return;
+ }
+ if (!isValidURL.test(linkUrl)) {
+ toast.error("Please enter a valid URL to proceed.");
+ return;
+ }
+
+ try {
+ setUploading(true);
+ const finalUrl = linkUrl.startsWith("http")
+ ? linkUrl
+ : `https://${linkUrl}`;
+
+ const response = await fetch(
+ `/api/teams/${teamInfo?.currentTeam?.id}/documents`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name: createLinkFileName(finalUrl),
+ url: finalUrl,
+ numPages: 1,
+ type: "link",
+ createLink: false,
+ folderPathName: currentFolderPath?.join("/"),
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const { error } = await response.json();
+ toast.error(error);
+ return;
+ }
+
+ const document = await response.json();
+
+ if (isDataroom && dataroomId) {
+ const dataroomResponse = await addDocumentToDataroom({
+ documentId: document.id,
+ folderPathName: currentFolderPath?.join("/"),
+ });
+
+ if (dataroomResponse?.ok) {
+ const dataroomDocument =
+ (await dataroomResponse.json()) as DataroomDocument & {
+ dataroom: {
+ _count: { viewerGroups: number; permissionGroups: number };
+ };
+ };
+
+ await applyUnifiedPermissionsToDocument(
+ document,
+ dataroomDocument,
+ currentFolderPath,
+ );
+ }
+
+ analytics.capture("Document Added", {
+ documentId: document.id,
+ name: document.name,
+ numPages: document.numPages,
+ path: router.asPath,
+ type: "link",
+ teamId: teamId,
+ dataroomId: dataroomId,
+ $set: {
+ teamId: teamId,
+ teamPlan: plan,
+ },
+ });
+
+ return;
+ }
+
+ if (!newVersion) {
+ toast.success("Link saved. Redirecting to document page...");
+
+ analytics.capture("Document Added", {
+ documentId: document.id,
+ name: document.name,
+ fileSize: null,
+ path: router.asPath,
+ type: "link",
+ teamId: teamId,
+ $set: {
+ teamId: teamId,
+ teamPlan: plan,
+ },
+ });
+
+ // redirect to the document page
+ router.push("/documents/" + document.id);
+ }
+ } catch (error) {
+ setUploading(false);
+ toast.error("An error occurred while saving the link.");
+ console.error("An error occurred while processing the link: ", error);
+ } finally {
+ setUploading(false);
+ setIsOpen(false);
+ }
+ };
+
const clearModelStates = () => {
currentFile !== null && setCurrentFile(null);
notionLink !== null && setNotionLink(null);
+ linkUrl !== null && setLinkUrl(null);
setIsOpen(!isOpen);
setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen);
};
@@ -545,9 +676,10 @@ export function AddDocumentModal({
{!newVersion ? (
-
+
Document
Notion Page
+ Link
) : (
@@ -679,6 +811,54 @@ export function AddDocumentModal({
)}
+ {!newVersion && (
+
+
+
+ Share a Link
+
+ Add an external link as a document.
+
+
+
+
+
+
+
+ )}
diff --git a/components/documents/document-header.tsx b/components/documents/document-header.tsx
index 5dcee7227..630fa56d4 100644
--- a/components/documents/document-header.tsx
+++ b/components/documents/document-header.tsx
@@ -537,10 +537,9 @@ export default function DocumentHeader({
- {primaryVersion.type !== "notion" &&
- primaryVersion.type !== "sheet" &&
- primaryVersion.type !== "zip" &&
- primaryVersion.type !== "video" &&
+ {!["link", "notion", "sheet", "zip", "video"].includes(
+ primaryVersion?.type ?? "",
+ ) &&
(!orientationLoading ? (
))}
-
- {primaryVersion.type !== "notion" && (
+ {!["link", "notion"].includes(primaryVersion?.type ?? "") && (
)}
- {primaryVersion.type !== "notion" &&
- primaryVersion.type !== "zip" &&
- primaryVersion.type !== "map" &&
- primaryVersion.type !== "email" && (
-
- isFree
- ? handleUpgradeClick(
- PlanEnum.Business,
- "download-only-document",
- )
- : toggleDownloadOnly()
- }
- >
- {prismaDocument.downloadOnly ? (
- <>
-
- Set viewable
- >
- ) : (
- <>
-
- Set download only{" "}
- {isFree && }
- >
- )}
-
- )}
+ {!["notion", "zip", "map", "email", "link"].includes(
+ primaryVersion?.type ?? "",
+ ) && (
+
+ isFree
+ ? handleUpgradeClick(
+ PlanEnum.Business,
+ "download-only-document",
+ )
+ : toggleDownloadOnly()
+ }
+ >
+ {prismaDocument.downloadOnly ? (
+ <>
+
+ Set viewable
+ >
+ ) : (
+ <>
+
+ Set download only{" "}
+ {isFree && }
+ >
+ )}
+
+ )}
{prismaDocument.type === "notion" && (
<>
@@ -792,15 +789,16 @@ export default function DocumentHeader({
{/* Download latest version */}
- {primaryVersion.type !== "notion" &&
- primaryVersion.type !== "video" && (
- downloadDocument(primaryVersion)}
- >
-
- Download latest version
-
- )}
+ {!["notion", "video", "link"].includes(
+ primaryVersion?.type ?? "",
+ ) && (
+ downloadDocument(primaryVersion)}
+ >
+
+ Download latest version
+
+ )}
diff --git a/components/documents/link-analytics.tsx b/components/documents/link-analytics.tsx
new file mode 100644
index 000000000..36a841504
--- /dev/null
+++ b/components/documents/link-analytics.tsx
@@ -0,0 +1,168 @@
+import { useSearchParams } from "next/navigation";
+import { useRouter } from "next/router";
+
+import { useState } from "react";
+
+import { DocumentVersion, View } from "@prisma/client";
+import { Label } from "@radix-ui/react-label";
+import { formatDistanceToNow } from "date-fns";
+import useSWR from "swr";
+
+import { fetcher } from "@/lib/utils";
+
+import StatsElement from "@/components/documents/stats-element";
+import { Card, CardContent } from "@/components/ui/card";
+import LoadingSpinner from "@/components/ui/loading-spinner";
+
+import { Switch } from "../ui/switch";
+
+interface LinkAnalyticsProps {
+ teamId: string;
+ documentId: string;
+ primaryVersion: DocumentVersion;
+}
+
+export default function LinkAnalytics({
+ teamId,
+ documentId,
+ primaryVersion,
+}: LinkAnalyticsProps) {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const initialExclude = searchParams?.get("excludeInternal") === "true";
+ const [excludeTeamMembers, setExcludeTeamMembers] =
+ useState(initialExclude);
+
+ const { data, error, isLoading } = useSWR<{
+ views: View[];
+ totalViews: number;
+ }>(
+ `/api/teams/${teamId}/documents/${documentId}/link-analytics${excludeTeamMembers ? "?excludeTeamMembers=true" : ""}`,
+ fetcher,
+ );
+
+ if (error) {
+ console.error("Error loading link analytics:", error);
+ return null;
+ }
+
+ const onToggle = (checked: boolean) => {
+ setExcludeTeamMembers(checked);
+ const params = new URLSearchParams(searchParams?.toString());
+ params.set("excludeInternal", checked.toString());
+ router.push(`${documentId}/?${params.toString()}`);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!data?.views?.length) {
+ const emptyStats = [
+ {
+ name: "Total Views",
+ value: "0",
+ active: false,
+ },
+ {
+ name: "Total Clicks",
+ value: "0",
+ active: false,
+ },
+ {
+ name: "Last Visited",
+ value: "Never",
+ active: false,
+ },
+ ];
+
+ return (
+
+
+
+
+ Exclude internal visits
+
+
+
+ {emptyStats.map((stat, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ const totalViews = data?.views?.length ?? 0;
+ const totalClicks = data?.views?.filter((v) => !!v.redirectAt).length ?? 0;
+ const lastRedirected = data?.views?.length
+ ? data.views
+ .map((v) => v.redirectAt)
+ .filter((d) => typeof d === "string" && !!d)
+ .sort(
+ (a, b) =>
+ new Date(b as string).getTime() - new Date(a as string).getTime(),
+ )[0]
+ : null;
+
+ const stats = [
+ {
+ name: "Total Views",
+ value: totalViews.toString(),
+ active: true,
+ },
+ {
+ name: "Total Clicks",
+ value: totalClicks.toString(),
+ active: true,
+ },
+ {
+ name: "Last Redirection",
+ value: lastRedirected
+ ? formatDistanceToNow(new Date(lastRedirected), { addSuffix: true })
+ : "Never Visited",
+ active: true,
+ },
+ ];
+
+ return (
+
+
+
+
+ Exclude internal visits
+
+
+
+
+ {stats.map((stat, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/links/links-table.tsx b/components/links/links-table.tsx
index 23db65bae..395b1edd9 100644
--- a/components/links/links-table.tsx
+++ b/components/links/links-table.tsx
@@ -896,6 +896,7 @@ export default function LinksTable({
>
diff --git a/components/links/links-visitors.tsx b/components/links/links-visitors.tsx
index 1cdc56803..a0b314120 100644
--- a/components/links/links-visitors.tsx
+++ b/components/links/links-visitors.tsx
@@ -1,17 +1,20 @@
+import { useLinkVisits } from "@/lib/swr/use-link";
+import { durationFormat, timeAgo } from "@/lib/utils";
+
import { Gauge } from "@/components/ui/gauge";
import { Skeleton } from "@/components/ui/skeleton";
import { TableCell, TableRow } from "@/components/ui/table";
+import { BadgeTooltip } from "@/components/ui/tooltip";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
-import { useLinkVisits } from "@/lib/swr/use-link";
-import { durationFormat, timeAgo } from "@/lib/utils";
-
export default function LinksVisitors({
linkId,
linkName,
+ isLink = false,
}: {
linkId: string;
linkName: string;
+ isLink?: boolean;
}) {
const { views } = useLinkVisits(linkId);
@@ -36,9 +39,23 @@ export default function LinksVisitors({
- {durationFormat(view.totalDuration)}
+ {isLink ? (
+
+
+ {view.redirectAt ? timeAgo(view.redirectAt) : "-"}
+
+
+ ) : (
+ durationFormat(view.totalDuration)
+ )}
-
+ ) : viewData.fileType === "link" ? (
+
) : document.downloadOnly ? (
(undefined);
+ const hasRedirected = useRef(false);
+
+ const trackLinkClick = useCallback(() => {
+ if (!isPreview && viewId) {
+ try {
+ navigator.sendBeacon?.(
+ "/api/record_link_click",
+ new Blob([JSON.stringify({ viewId, linkId })], {
+ type: "application/json",
+ }),
+ );
+ } catch (error) {
+ console.error("Failed to track link click:", error);
+ }
+ }
+ }, [isPreview, viewId, linkId]);
+
+ const redirect = useCallback(() => {
+ if (hasRedirected.current) return;
+ hasRedirected.current = true;
+ trackLinkClick();
+ window.location.replace(linkUrl);
+ }, [trackLinkClick, linkUrl]);
+
+ useEffect(() => {
+ let isMounted = true;
+ setCountdown(5);
+ autoRedirectTimer.current = window.setTimeout(() => {
+ if (isMounted) redirect();
+ }, 5000);
+
+ const interval = window.setInterval(() => {
+ setCountdown((prev) => (prev > 1 ? prev - 1 : 1));
+ }, 1000);
+
+ return () => {
+ isMounted = false;
+ if (autoRedirectTimer.current) {
+ clearTimeout(autoRedirectTimer.current);
+ }
+ clearInterval(interval);
+ };
+ }, [redirect]);
+
+ const handleOpenLink = async () => {
+ if (autoRedirectTimer.current) {
+ clearTimeout(autoRedirectTimer.current);
+ autoRedirectTimer.current = undefined;
+ }
+ setIsOpening(true);
+ await trackLinkClick();
+ window.open(linkUrl, "_blank", "noopener,noreferrer");
+ window.location.replace("https://www.papermark.com/home");
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {documentName}
+
+
+ External Link Document
+
+
+
+ e.preventDefault()}
+ >
+
+
+
+ {getDomainFromUrl(linkUrl)}
+
+
+ {linkUrl}
+
+
+
+
+
+ You will be redirected to the external link in{" "}
+ {countdown} second
+ {countdown !== 1 ? "s" : ""}. If you are not automatically
+ redirected, please click the button below to open the link.
+
+
+
+ {/* Optional alert message */}
+ {/*
+
+
+ External Link Redirection
+
+ You will be redirected to an external website. Please verify
+ that you trust the source before proceeding.
+
+
+
*/}
+
+
+
+
+ {isOpening ? "Opening..." : "Open External Link"}
+
+
+
+
+ {screenshotProtectionEnabled &&
}
+ {showPoweredByBanner ?
: null}
+
+
+ >
+ );
+}
diff --git a/components/visitors/visitors-table.tsx b/components/visitors/visitors-table.tsx
index 6954556cf..a6a650b21 100644
--- a/components/visitors/visitors-table.tsx
+++ b/components/visitors/visitors-table.tsx
@@ -64,9 +64,11 @@ import VisitorVideoChart from "./visitor-video-chart";
export default function VisitorsTable({
primaryVersion,
isVideo = false,
+ isLink = false,
}: {
primaryVersion: DocumentVersion;
isVideo?: boolean;
+ isLink?: boolean;
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
@@ -138,7 +140,9 @@ export default function VisitorsTable({
Name
- Visit Duration
+
+ {isLink ? "Redirect Time" : "Visit Duration"}
+
Visit Completion
Last Viewed
@@ -189,7 +193,21 @@ export default function VisitorsTable({
{/* Duration */}
- {durationFormat(view.totalDuration)}
+ {isLink ? (
+
+
+ {view.redirectAt ? timeAgo(view.redirectAt) : "-"}
+
+
+ ) : (
+ durationFormat(view.totalDuration)
+ )}
{/* Completion */}
@@ -251,6 +269,102 @@ export default function VisitorsTable({
);
}
+ if (isLink) {
+ return (
+
+ {/* Name */}
+
+
+
+
+
+
+ {view.viewerEmail ? (
+ <>{view.viewerEmail}>
+ ) : (
+ "Anonymous"
+ )}
+
+
+ {view.link.name ? view.link.name : view.linkId}
+
+
+
+
+
+ {/* Duration */}
+
+
+
+
+ {view.redirectAt ? timeAgo(view.redirectAt) : "-"}
+
+
+
+
+ {/* Completion */}
+
+
+
+
+
+ {/* Last Viewed */}
+
+
+ {timeAgo(view.viewedAt)}
+
+
+ {/* Actions */}
+
+
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ Open menu
+
+
+
+
+ Actions
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ handleArchiveView(
+ view.id,
+ view.documentId ?? "",
+ view.isArchived,
+ );
+ }}
+ disabled={isLoading}
+ >
+
+ Archive
+
+
+
+
+
+ );
+ }
return (
<>
@@ -336,7 +450,26 @@ export default function VisitorsTable({
{/* Duration */}
- {durationFormat(view.totalDuration)}
+ {isLink ? (
+
+
+ {view.redirectAt
+ ? timeAgo(view.redirectAt)
+ : "-"}
+
+
+ ) : (
+ durationFormat(view.totalDuration)
+ )}
{/* Completion */}
diff --git a/lib/trigger/export-visits.ts b/lib/trigger/export-visits.ts
index df7385bda..a2502d3cf 100644
--- a/lib/trigger/export-visits.ts
+++ b/lib/trigger/export-visits.ts
@@ -195,6 +195,7 @@ async function exportDocumentVisits(
id: true,
name: true,
numPages: true,
+ type: true,
versions: {
orderBy: { createdAt: "desc" },
select: {
@@ -288,77 +289,112 @@ async function exportDocumentVisits(
viewedAt: view.viewedAt,
});
- // Rate-limited calls to tinybird
- const [duration, userAgentData] = await Promise.all([
- tinybirdLimiter.schedule(() =>
- getViewPageDuration({
- documentId: docId,
- viewId: view.id,
- since: 0,
- }),
- ),
- tinybirdLimiter.schedule(async () => {
- const result = await getViewUserAgent({
- viewId: view.id,
- });
-
- if (!result || result.rows === 0) {
- return getViewUserAgent_v2({
+ let rowData;
+ let relevantDocumentVersion = document.versions[0];
+
+ if (document.type === "link") {
+ rowData = [
+ view.viewedAt.toISOString(),
+ view.viewerName || "NaN",
+ view.viewerEmail || "NaN",
+ view.link?.name || "NaN",
+ "0.0",
+ "100.00%",
+ relevantDocumentVersion?.versionNumber ||
+ document.versions[0]?.versionNumber ||
+ "NaN",
+ view.downloadedAt ? view.downloadedAt.toISOString() : "NaN",
+ view.verified ? "Yes" : "No",
+ view.agreementResponse ? "Yes" : "NaN",
+ view.agreementResponse?.agreement.name || "NaN",
+ view.agreementResponse?.agreement.content || "NaN",
+ view.agreementResponse?.createdAt.toISOString() || "NaN",
+ view.dataroomId ? "Yes" : "No",
+ "NaN",
+ "NaN",
+ "NaN",
+ ];
+ if (!isProPlan) {
+ rowData.push(
+ "NaN", // country
+ "NaN", // city
+ view.customFieldResponse?.data
+ ? JSON.stringify(view.customFieldResponse.data)
+ : "NaN",
+ );
+ }
+ } else {
+ // Rate-limited calls to tinybird
+ const [duration, userAgentData] = await Promise.all([
+ tinybirdLimiter.schedule(() =>
+ getViewPageDuration({
documentId: docId,
viewId: view.id,
since: 0,
+ }),
+ ),
+ tinybirdLimiter.schedule(async () => {
+ const result = await getViewUserAgent({
+ viewId: view.id,
});
- }
- return result;
- }),
- ]);
+ if (!result || result.rows === 0) {
+ return getViewUserAgent_v2({
+ documentId: docId,
+ viewId: view.id,
+ since: 0,
+ });
+ }
- const relevantDocumentVersion = document.versions.find(
- (version) => version.createdAt <= view.viewedAt,
- );
+ return result;
+ }),
+ ]);
- const numPages =
- relevantDocumentVersion?.numPages || document.numPages || 0;
- const completionRate = numPages
- ? (duration.data.length / numPages) * 100
- : 0;
+ relevantDocumentVersion = document.versions.find(
+ (version) => version.createdAt <= view.viewedAt,
+ ) || document.versions[0];
- const totalDuration = duration.data.reduce(
- (total, data) => total + data.sum_duration,
- 0,
- );
+ const numPages =
+ relevantDocumentVersion?.numPages || document.numPages || 0;
+ const completionRate = numPages
+ ? (duration.data.length / numPages) * 100
+ : 0;
+
+ const totalDuration = duration.data.reduce(
+ (total, data) => total + data.sum_duration,
+ 0,
+ );
- const rowData = [
- view.viewedAt.toISOString(),
- view.viewerName || "NaN",
- view.viewerEmail || "NaN",
- view.link?.name || "NaN",
- (totalDuration / 1000.0).toFixed(1),
- completionRate.toFixed(2) + "%",
- relevantDocumentVersion?.versionNumber ||
+ rowData = [
+ view.viewedAt.toISOString(),
+ view.viewerName || "NaN",
+ view.viewerEmail || "NaN",
+ view.link?.name || "NaN",
+ (totalDuration / 1000.0).toFixed(1),
+ completionRate.toFixed(2) + "%",
+ relevantDocumentVersion?.versionNumber ||
document.versions[0]?.versionNumber ||
"NaN",
- view.downloadedAt ? view.downloadedAt.toISOString() : "NaN",
- view.verified ? "Yes" : "No",
- view.agreementResponse ? "Yes" : "NaN",
- view.agreementResponse?.agreement.name || "NaN",
- view.agreementResponse?.agreement.content || "NaN",
- view.agreementResponse?.createdAt.toISOString() || "NaN",
- view.dataroomId ? "Yes" : "No",
- userAgentData?.data[0]?.browser || "NaN",
- userAgentData?.data[0]?.os || "NaN",
- userAgentData?.data[0]?.device || "NaN",
- ];
-
- if (!isProPlan) {
- rowData.push(
- userAgentData?.data[0]?.country || "NaN",
- userAgentData?.data[0]?.city || "NaN",
- view.customFieldResponse?.data
- ? JSON.stringify(view.customFieldResponse.data)
- : "NaN",
- );
+ view.downloadedAt ? view.downloadedAt.toISOString() : "NaN",
+ view.verified ? "Yes" : "No",
+ view.agreementResponse ? "Yes" : "NaN",
+ view.agreementResponse?.agreement.name || "NaN",
+ view.agreementResponse?.agreement.content || "NaN",
+ view.agreementResponse?.createdAt.toISOString() || "NaN",
+ view.dataroomId ? "Yes" : "No",
+ userAgentData?.data[0]?.browser || "NaN",
+ userAgentData?.data[0]?.os || "NaN",
+ userAgentData?.data[0]?.device || "NaN",
+ ];
+ if (!isProPlan) {
+ rowData.push(
+ userAgentData?.data[0]?.country || "NaN",
+ userAgentData?.data[0]?.city || "NaN",
+ view.customFieldResponse?.data
+ ? JSON.stringify(view.customFieldResponse.data)
+ : "NaN",
+ );
+ }
}
csvRows.push(createCsvRow(rowData));
diff --git a/lib/utils/get-file-icon.tsx b/lib/utils/get-file-icon.tsx
index d80231877..3cda9fbfa 100644
--- a/lib/utils/get-file-icon.tsx
+++ b/lib/utils/get-file-icon.tsx
@@ -1,4 +1,4 @@
-import { FileIcon, MailIcon } from "lucide-react";
+import { FileIcon, LinkIcon, MailIcon } from "lucide-react";
import CadIcon from "@/components/shared/icons/files/cad";
import DocsIcon from "@/components/shared/icons/files/docs";
@@ -64,6 +64,8 @@ export function fileIcon({
case "application/vnd.ms-outlook":
case "email":
return ;
+ case "link":
+ return ;
default:
return ;
}
diff --git a/pages/api/links/[id]/visits.ts b/pages/api/links/[id]/visits.ts
index c409aab0c..3d91ace52 100644
--- a/pages/api/links/[id]/visits.ts
+++ b/pages/api/links/[id]/visits.ts
@@ -1,5 +1,4 @@
import { NextApiRequest, NextApiResponse } from "next";
-
import { getServerSession } from "next-auth/next";
import { LIMITS } from "@/lib/constants";
@@ -16,131 +15,127 @@ export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
- if (req.method === "GET") {
- // GET /api/links/:id/visits
- const session = await getServerSession(req, res, authOptions);
- if (!session) {
- return res.status(401).end("Unauthorized");
- }
-
- // get link id from query params
- const { id } = req.query as { id: string };
+ if (req.method !== "GET") {
+ res.setHeader("Allow", ["GET"]);
+ return res.status(405).end(`Method ${req.method} Not Allowed`);
+ }
- const userId = (session.user as CustomUser).id;
+ const session = await getServerSession(req, res, authOptions);
+ if (!session) {
+ return res.status(401).end("Unauthorized");
+ }
- try {
- // get the numPages from document
- const result = await prisma.link.findUnique({
- where: {
- id: id,
- },
- select: {
- document: {
- select: {
- id: true,
- numPages: true,
- versions: {
- where: { isPrimary: true },
- orderBy: { createdAt: "desc" },
- take: 1,
- select: { numPages: true },
- },
- team: {
- select: {
- id: true,
- plan: true,
- },
- },
+ const { id } = req.query as { id: string };
+ const userId = (session.user as CustomUser).id;
+
+ try {
+ const result = await prisma.link.findUnique({
+ where: { id },
+ select: {
+ document: {
+ select: {
+ id: true,
+ type: true,
+ numPages: true,
+ versions: {
+ where: { isPrimary: true },
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ select: { numPages: true },
},
- },
- },
- });
-
- const docId = result?.document!.id!;
-
- // check if the the team that own the document has the current user
- await getDocumentWithTeamAndUser({
- docId,
- userId,
- options: {
- team: {
- select: {
- users: {
- select: {
- userId: true,
+ team: {
+ select: {
+ id: true,
+ plan: true,
+ users: {
+ select: {
+ userId: true,
+ },
},
},
},
},
},
- });
+ },
+ });
- const numPages =
- result?.document?.versions[0]?.numPages ||
- result?.document?.numPages ||
- 0;
+ const document = result?.document;
- const views = await prisma.view.findMany({
- where: {
- linkId: id,
- },
- orderBy: {
- viewedAt: "desc",
- },
- });
-
- // limit the number of views to 20 on free plan
- const limitedViews =
- result?.document?.team?.plan === "free"
- ? views.slice(0, LIMITS.views)
- : views;
+ if (!document) {
+ return res.status(404).json({ error: "Document not found" });
+ }
- const durationsPromises = limitedViews.map((view) => {
- return getViewPageDuration({
+ const docId = document.id;
+
+ // ✅ Ensure user belongs to the document’s team
+ await getDocumentWithTeamAndUser({
+ docId,
+ userId,
+ options: {
+ team: {
+ select: {
+ users: {
+ select: { userId: true },
+ },
+ },
+ },
+ },
+ });
+
+ const numPages =
+ document.versions?.[0]?.numPages || document.numPages || 0;
+
+ const views = await prisma.view.findMany({
+ where: { linkId: id },
+ orderBy: { viewedAt: "desc" },
+ });
+
+ const limitedViews =
+ document.team?.plan === "free" ? views.slice(0, LIMITS.views) : views;
+
+ const isLinkType = document.type === "link";
+
+ const viewsWithDuration = await Promise.all(
+ limitedViews.map(async (view) => {
+ if (isLinkType) {
+ return {
+ ...view,
+ duration: { data: [] },
+ totalDuration: 0,
+ completionRate: "100",
+ };
+ }
+
+ const duration = await getViewPageDuration({
documentId: view.documentId!,
viewId: view.id,
since: 0,
});
- });
- const durations = await Promise.all(durationsPromises);
-
- // Sum up durations for each view
- const summedDurations = durations.map((duration) => {
- return duration.data.reduce(
- (totalDuration, data) => totalDuration + data.sum_duration,
+ const totalDuration = duration.data.reduce(
+ (totalDuration, d) => totalDuration + d.sum_duration,
0,
);
- });
- // Construct the response combining views and their respective durations
- const viewsWithDuration = limitedViews.map((view, index) => {
- // calculate the completion rate
const completionRate = numPages
- ? (durations[index].data.length / numPages) * 100
- : 0;
+ ? ((duration.data.length / numPages) * 100).toFixed()
+ : "0";
return {
...view,
- duration: durations[index],
- totalDuration: summedDurations[index],
- completionRate: completionRate.toFixed(),
+ duration,
+ totalDuration,
+ completionRate,
};
- });
-
- // TODO: Check that the user is owner of the links, otherwise return 401
-
- return res.status(200).json(viewsWithDuration);
- } catch (error) {
- log({
- message: `Failed to get views for link: _${id}_. \n\n ${error} \n\n*Metadata*: \`{userId: ${userId}}\``,
- type: "error",
- });
- errorhandler(error, res);
- }
- } else {
- // We only allow GET requests
- res.setHeader("Allow", ["GET"]);
- return res.status(405).end(`Method ${req.method} Not Allowed`);
+ }),
+ );
+
+ return res.status(200).json(viewsWithDuration);
+ } catch (error) {
+ log({
+ message: `Failed to get views for link: _${id}_. \n\n ${error} \n\n*Metadata*: \`{userId: ${userId}}\``,
+ type: "error",
+ });
+ errorhandler(error, res);
}
}
diff --git a/pages/api/record_link_click.ts b/pages/api/record_link_click.ts
new file mode 100644
index 000000000..88785c73e
--- /dev/null
+++ b/pages/api/record_link_click.ts
@@ -0,0 +1,55 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import { z } from "zod";
+import prisma from "@/lib/prisma";
+import { log } from "@/lib/utils";
+
+const bodyValidation = z.object({
+ viewId: z.string(),
+ linkId: z.string(),
+});
+
+export default async function handle(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ if (req.method !== "POST") {
+ res.status(405).json({ message: "Method Not Allowed" });
+ return;
+ }
+
+ const { viewId, linkId } = req.body as {
+ viewId: string;
+ linkId: string;
+ };
+
+ const result = bodyValidation.safeParse({ viewId, linkId });
+ if (!result.success) {
+ return res.status(400).json({ error: `Invalid body: ${result.error.message}` });
+ }
+
+ try {
+ const link = await prisma.link.findUnique({ where: { id: linkId } });
+ if (!link) return res.status(404).json({ message: "Link not found" });
+
+ const view = await prisma.view.findUnique({
+ where: { id: viewId },
+ });
+ if (!view || view.linkId !== linkId) {
+ return res.status(404).json({ message: "View not found" });
+ }
+
+ const updated = await prisma.view.update({
+ where: { id: viewId },
+ data: { redirectAt: new Date().toISOString() },
+ });
+
+ res.status(200).json({ message: "Link click recorded", view: updated });
+ } catch (error) {
+ log({
+ message: `Failed to record link click for ${linkId}.\n\n ${error}`,
+ type: "error",
+ mention: true,
+ });
+ res.status(500).json({ message: (error as Error).message });
+ }
+}
\ No newline at end of file
diff --git a/pages/api/teams/[teamId]/documents/[id]/link-analytics.ts b/pages/api/teams/[teamId]/documents/[id]/link-analytics.ts
new file mode 100644
index 000000000..78d77947a
--- /dev/null
+++ b/pages/api/teams/[teamId]/documents/[id]/link-analytics.ts
@@ -0,0 +1,107 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import { getServerSession } from "next-auth/next";
+
+import { authOptions } from "@/pages/api/auth/[...nextauth]";
+import prisma from "@/lib/prisma";
+import { CustomUser } from "@/lib/types";
+import { View } from "@prisma/client";
+
+interface LinkAnalyticsResponse {
+ views: View[];
+ totalViews: number;
+}
+
+export default async function handle(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method !== "GET") {
+ return res.status(405).json({ message: "Method not allowed" });
+ }
+
+ try {
+ const session = await getServerSession(req, res, authOptions);
+ const user = session?.user as CustomUser;
+
+ if (!user?.id) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ const { teamId, id: documentId, excludeTeamMembers } = req.query as {
+ teamId: string;
+ id: string;
+ excludeTeamMembers?: string;
+ };
+
+ // Step 1: Check document access and fetch data
+ const document = await prisma.document.findFirst({
+ where: {
+ id: documentId,
+ teamId,
+ team: {
+ users: {
+ some: {
+ userId: user.id,
+ },
+ },
+ },
+ },
+ include: {
+ views: true,
+ versions: {
+ where: { isPrimary: true },
+ select: { type: true },
+ },
+ },
+ });
+
+ if (!document) {
+ return res.status(404).json({ message: "Document not found" });
+ }
+
+ // Step 2: Ensure it's a link document
+ if (document.versions[0]?.type !== "link") {
+ return res.status(400).json({ message: "Not a link document" });
+ }
+
+ const allViews = document.views ?? [];
+ if (allViews.length === 0) {
+ return res.status(200).json({ views: [], totalViews: 0 });
+ }
+
+ let excludedEmails = new Set();
+
+ if (excludeTeamMembers) {
+ const teamUsers = await prisma.user.findMany({
+ where: {
+ teams: { some: { teamId } },
+ },
+ select: { email: true },
+ });
+
+ excludedEmails = new Set(teamUsers.map((u) => u.email).filter((e): e is string => !!e));
+ }
+
+ const filteredViews = allViews.filter((view) => {
+ const isArchived = view.isArchived;
+ const isInternal = excludedEmails.has(view.viewerEmail || "");
+ return !isArchived && !(excludeTeamMembers && isInternal);
+ });
+
+ return res.status(200).json({
+ views: filteredViews,
+ totalViews: filteredViews.length,
+ });
+
+ } catch (error) {
+ console.error("Link analytics error:", {
+ message: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+
+ return res.status(500).json({
+ message: "Internal Server Error",
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+}
diff --git a/pages/api/teams/[teamId]/documents/[id]/views/index.ts b/pages/api/teams/[teamId]/documents/[id]/views/index.ts
index 51ec7c439..cd5ecc145 100644
--- a/pages/api/teams/[teamId]/documents/[id]/views/index.ts
+++ b/pages/api/teams/[teamId]/documents/[id]/views/index.ts
@@ -167,6 +167,26 @@ async function getDocumentViews(views: ViewWithExtras[], document: Document) {
});
}
+async function getLinkViews(views: ViewWithExtras[], document: Document) {
+ return views.map((view, index) => {
+ const relevantDocumentVersion = document.versions.find(
+ (version) => version.createdAt <= view.viewedAt,
+ );
+
+ const numPages =
+ relevantDocumentVersion?.numPages || document.numPages || 0;
+
+ return {
+ ...view,
+ duration: { data: [] },
+ totalDuration: 0,
+ completionRate: 100,
+ versionNumber: relevantDocumentVersion?.versionNumber || 1,
+ versionNumPages: numPages,
+ };
+ });
+}
+
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
@@ -296,6 +316,8 @@ export default async function handle(
document,
videoEvents,
);
+ } else if (document.type === "link") {
+ viewsWithDuration = await getLinkViews(limitedViews, document);
} else {
viewsWithDuration = await getDocumentViews(limitedViews, document);
}
diff --git a/pages/documents/[id]/index.tsx b/pages/documents/[id]/index.tsx
index 506454522..5ca93ae32 100644
--- a/pages/documents/[id]/index.tsx
+++ b/pages/documents/[id]/index.tsx
@@ -12,6 +12,7 @@ import useLimits from "@/lib/swr/use-limits";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import DocumentHeader from "@/components/documents/document-header";
import { DocumentPreviewButton } from "@/components/documents/document-preview-button";
+import LinkAnalytics from "@/components/documents/link-analytics";
import { StatsComponent } from "@/components/documents/stats";
import VideoAnalytics from "@/components/documents/video-analytics";
import AppLayout from "@/components/layouts/app";
@@ -89,12 +90,13 @@ export default function DocumentPage() {
/>
{/* Document Analytics */}
- {primaryVersion.type !== "video" && (
-
- )}
+ {primaryVersion.type !== "video" &&
+ primaryVersion.type !== "link" && (
+
+ )}
{/* Video Analytics */}
{primaryVersion.type === "video" && (
@@ -105,6 +107,14 @@ export default function DocumentPage() {
/>
)}
+ {primaryVersion.type === "link" && (
+
+ )}
+
{/* Links */}