Skip to content
Open
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
63 changes: 40 additions & 23 deletions app/api/views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,36 +469,52 @@ export async function POST(request: NextRequest) {

try {
let viewer: { id: string; verified: boolean } | null = null;
if (email && !isPreview) {
// find or create a viewer
console.time("find-viewer");
viewer = await prisma.viewer.findUnique({
where: {
teamId_email: {
teamId: link.teamId!,
email: email,
if (!isPreview) {
if (email) {
// find or create a viewer with email
console.time("find-viewer");
viewer = await prisma.viewer.findUnique({
where: {
teamId_email: {
teamId: link.teamId!,
email: email,
},
},
},
select: { id: true, verified: true },
});
console.timeEnd("find-viewer");

if (!viewer) {
console.time("create-viewer");
select: { id: true, verified: true },
});
console.timeEnd("find-viewer");

if (!viewer) {
console.time("create-viewer");
viewer = await prisma.viewer.create({
data: {
email: email,
verified: isEmailVerified,
teamId: link.teamId!,
},
select: { id: true, verified: true },
});
console.timeEnd("create-viewer");
} else if (!viewer.verified && isEmailVerified) {
await prisma.viewer.update({
where: { id: viewer.id },
data: { verified: isEmailVerified },
});
}
} else {
// For anonymous viewers, create with a generated email based on IP and user agent
// This allows anonymous users to participate in conversations
const anonymousEmail = `anonymous-${ipAddress?.replace(/\./g, '-') || 'unknown'}-${Date.now()}@anonymous.papermark.com`;
console.time("create-anonymous-viewer");
viewer = await prisma.viewer.create({
data: {
email: email,
verified: isEmailVerified,
email: anonymousEmail,
verified: false,
teamId: link.teamId!,
},
select: { id: true, verified: true },
});
console.timeEnd("create-viewer");
} else if (!viewer.verified && isEmailVerified) {
await prisma.viewer.update({
where: { id: viewer.id },
data: { verified: isEmailVerified },
});
console.timeEnd("create-anonymous-viewer");
}
}
Comment on lines 470 to 519
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Anonymous viewer email construction will throw at runtime

In the anonymous viewer path you’re treating the ipAddress import as if it were a string:

const anonymousEmail = `anonymous-${ipAddress?.replace(/\./g, '-') || 'unknown'}-${Date.now()}@anonymous.papermark.com`;

But ipAddress is a function from @vercel/functions (as you correctly use elsewhere via ipAddress(request)). Here, ipAddress is never called, so ipAddress?.replace attempts to invoke .replace on the function object, which doesn’t exist and will throw when this branch is hit.

This will break any non‑preview, non‑email‑protected view where email is absent (the new anonymous viewer flow).

A safe version would be:

-        } else {
-          // For anonymous viewers, create with a generated email based on IP and user agent
-          // This allows anonymous users to participate in conversations
-          const anonymousEmail = `anonymous-${ipAddress?.replace(/\./g, '-') || 'unknown'}-${Date.now()}@anonymous.papermark.com`;
-          console.time("create-anonymous-viewer");
-          viewer = await prisma.viewer.create({
-            data: {
-              email: anonymousEmail,
-              verified: false,
-              teamId: link.teamId!,
-            },
-            select: { id: true, verified: true },
-          });
-          console.timeEnd("create-anonymous-viewer");
-        }
+        } else {
+          // For anonymous viewers, create with a generated email based on IP
+          // This allows anonymous users to participate in conversations
+          const ipAddressValue = ipAddress(request);
+          const sanitizedIp =
+            typeof ipAddressValue === "string"
+              ? ipAddressValue.replace(/\./g, "-")
+              : "unknown";
+          const anonymousEmail = `anonymous-${sanitizedIp}-${Date.now()}@anonymous.papermark.com`;
+
+          console.time("create-anonymous-viewer");
+          viewer = await prisma.viewer.create({
+            data: {
+              email: anonymousEmail,
+              verified: false,
+              teamId: link.teamId!,
+            },
+            select: { id: true, verified: true },
+          });
+          console.timeEnd("create-anonymous-viewer");
+        }

The viewerId being returned in the response (viewerId: viewer?.id || undefined) is otherwise consistent with the new viewer flow and will be useful for conversations.

Also applies to: 679-683

🤖 Prompt for AI Agents
In app/api/views/route.ts around lines 470-519 (and the similar block at
679-683), the anonymous email construction treats ipAddress as a string but
ipAddress is a function; call ipAddress(request) to get the actual IP string,
guard that result before calling .replace, and fall back to 'unknown' if
undefined — e.g. compute const ip = ipAddress(request); const ipPart = ip ?
ip.replace(/\./g, '-') : 'unknown'; then use ipPart in the anonymous email
construction so no runtime error occurs when email is absent.


Expand Down Expand Up @@ -663,6 +679,7 @@ export async function POST(request: NextRequest) {
const returnObject = {
message: "View recorded",
viewId: !isPreview && newView ? newView.id : undefined,
viewerId: viewer?.id || undefined,
isPreview: isPreview ? true : undefined,
file:
(documentVersion &&
Expand Down
2 changes: 1 addition & 1 deletion components/links/link-sheet/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const DEFAULT_LINK_PROPS = (
groupId: groupId,
customFields: [],
tags: [],
enableConversation: false,
enableConversation: linkType === LinkType.DOCUMENT_LINK ? true : false,
enableUpload: false,
isFileRequestOnly: false,
uploadFolderId: null,
Expand Down
13 changes: 13 additions & 0 deletions components/links/link-sheet/link-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,25 @@ export const LinkOptions = ({
limits?.conversationsInDataroom)
}
handleUpgradeStateChange={handleUpgradeStateChange}
linkType={linkType}
/>
) : null}
</div>
</CollapsibleSection>
) : null}

{/* Conversation Section for Document Links */}
{linkType === LinkType.DOCUMENT_LINK ? (
<CollapsibleSection title="Q&A Conversations" defaultOpen={true}>
<ConversationSection
{...{ data, setData }}
isAllowed={isTrial || isPro || isBusiness || isDatarooms || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
linkType={linkType}
/>
</CollapsibleSection>
) : null}

<UpgradePlanModal
clickedPlan={upgradePlan}
open={openUpgradeModal}
Expand Down
11 changes: 9 additions & 2 deletions components/navigation-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Props = {
href: string;
segment: string | null;
tag?: string;
count?: number;
disabled?: boolean;
limited?: boolean;
}[];
Expand All @@ -37,13 +38,14 @@ export const NavMenu: React.FC<React.PropsWithChildren<Props>> = ({
<div className="flex w-full items-center overflow-x-auto px-4 pl-1">
<ul className="flex flex-row gap-4">
{navigation.map(
({ label, href, segment, tag, disabled, limited }) => (
({ label, href, segment, tag, count, disabled, limited }) => (
<NavItem
key={label}
label={label}
href={href}
segment={segment}
tag={tag}
count={count}
disabled={disabled}
limited={limited}
/>
Expand All @@ -61,6 +63,7 @@ const NavItem: React.FC<Props["navigation"][0]> = ({
href,
segment,
tag,
count,
disabled,
limited,
}) => {
Expand Down Expand Up @@ -121,7 +124,11 @@ const NavItem: React.FC<Props["navigation"][0]> = ({
)}
>
{label}
{tag ? (
{count !== undefined && count > 0 ? (
<div className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground">
{count > 99 ? "99+" : count}
</div>
) : tag ? (
<div className="text-content-subtle rounded border bg-background px-1 py-0.5 font-mono text-xs">
{tag}
</div>
Expand Down
2 changes: 2 additions & 0 deletions components/view/document-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export default function DocumentView({
} else {
const {
viewId,
viewerId,
file,
pages,
sheetData,
Expand Down Expand Up @@ -189,6 +190,7 @@ export default function DocumentView({

setViewData({
viewId,
viewerId,
file,
pages,
sheetData,
Expand Down
12 changes: 6 additions & 6 deletions components/view/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ export default function Nav({
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isDataroom &&
conversationsEnabled &&
!showConversations // if conversations are already open, don't toggle them
) {
Expand All @@ -225,7 +224,7 @@ export default function Nav({

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isDataroom, conversationsEnabled, showConversations]);
}, [conversationsEnabled, showConversations]);

return (
<nav
Expand Down Expand Up @@ -310,13 +309,14 @@ export default function Nav({
</Tooltip>
</TooltipProvider>
)}
{/* Conversation toggle button for dataroom documents */}
{isDataroom && conversationsEnabled && (
{/* Conversation toggle button for dataroom and document links */}
{conversationsEnabled && (
<Button
onClick={() => setShowConversations(!showConversations)}
className="bg-gray-900 text-white hover:bg-gray-900/80"
>
View FAQ
<MessageCircle className="mr-2 h-4 w-4" />
Q&A
</Button>
)}
{/* Annotations toggle button */}
Expand Down Expand Up @@ -473,7 +473,7 @@ export default function Nav({
</div>
</div>
</div>
{isDataroom && conversationsEnabled && showConversations ? (
{conversationsEnabled && showConversations ? (
<ConversationSidebar
dataroomId={dataroomId}
documentId={documentId}
Expand Down
11 changes: 6 additions & 5 deletions components/view/view-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,17 @@ export default function ViewData({
isPreview: viewData.isPreview,
linkId: link.id,
brand: brand,
viewerId: "viewerId" in viewData ? viewData.viewerId : undefined,
viewerId: viewData.viewerId || ("viewerId" in viewData ? viewData.viewerId : undefined),
isMobile: isMobile,
Comment on lines +87 to 88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Simplify viewerId assignment; conversationsEnabled gating looks good

The viewerId expression is effectively a no‑op and bypasses the "viewerId" in viewData type guard:

viewerId: viewData.viewerId || ("viewerId" in viewData ? viewData.viewerId : undefined),

This is equivalent to just viewData.viewerId. If you want the type guard to matter, you can simplify and tighten it to:

-    viewerId: viewData.viewerId || ("viewerId" in viewData ? viewData.viewerId : undefined),
+    viewerId: "viewerId" in viewData ? viewData.viewerId : undefined,

The updated conversationsEnabled expression correctly enables:

  • dataroom conversations when the dataroom view data flags them, and
  • document conversations when link.enableConversation === true and there is no dataroomId.

Also applies to: 93-98

🤖 Prompt for AI Agents
In components/view/view-data.tsx around lines 87-88 (and also apply the same fix
to lines 93-98), the current viewerId expression is redundant and bypasses the
type guard; replace the long expression with a single clear assignment using
either viewData.viewerId or, if you need the type guard to distinguish undefined
vs missing, use a conditional: "viewerId" in viewData ? viewData.viewerId :
undefined; update the other occurrences at 93-98 the same way so the
type-narrowing behaves as intended.

isDataroom: !!dataroomId,
documentId: document.id,
dataroomId: dataroomId,
conversationsEnabled:
!!dataroomId &&
("conversationsEnabled" in viewData
? viewData.conversationsEnabled
: false),
(!!dataroomId &&
("conversationsEnabled" in viewData
? viewData.conversationsEnabled
: false)) ||
(!dataroomId && link.enableConversation === true),
assistantEnabled: document.assistantEnabled,
allowDownload:
document.downloadOnly ||
Expand Down
Loading