Skip to content

Conversation

@shnai0
Copy link
Collaborator

@shnai0 shnai0 commented Dec 8, 2025

Summary by CodeRabbit

Release Notes

  • New Features
    • Q&A conversations on documents: Users can ask questions and share responses directly within documents.
    • Conversation management interface: Dedicated pages to view, search, and manage document conversations.
    • Anonymous viewer tracking: Document engagement is now recorded for all viewers, including those without accounts.
    • Navigation conversation badges: Unread message counts display in navigation menu for quick visibility.

✏️ Tip: You can customize this high-level summary in your review settings.

@shnai0 shnai0 requested a review from mfts as a code owner December 8, 2025 20:53
@vercel
Copy link

vercel bot commented Dec 8, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
papermark Error Error Dec 8, 2025 8:55pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 8, 2025

Walkthrough

This PR extends conversation functionality to document links. It refactors viewer creation to support anonymous viewers in the views API, modifies default link settings to conditionally enable conversations, adds conversation sections in link management, creates new API routes for document-scoped conversations, and introduces pages for viewing document conversations.

Changes

Cohort / File(s) Summary
Viewer Creation & Response
app/api/views/route.ts
Refactored viewer creation logic to support anonymous viewers with generated email based on IP and timestamp; added viewerId to the response payload.
Link Configuration
components/links/link-sheet/index.tsx, components/links/link-sheet/link-options.tsx
Made enableConversation conditional on link type (true for DOCUMENT_LINK); added Q&A Conversations section for document links with linkType prop support.
View Data & Navigation
components/view/document-view.tsx, components/view/nav.tsx, components/view/view-data.tsx, components/navigation-menu.tsx
Propagated viewerId through view data flow; removed dataroom gating from conversation toggle functionality; added optional count property to navigation items for badge rendering.
Conversation Components
ee/features/conversations/components/viewer/conversation-view-sidebar.tsx, ee/features/conversations/components/dashboard/conversation-list-item.tsx, ee/features/conversations/components/dashboard/link-option-conversation-section.tsx
Enhanced SWR key construction to support both dataroom and document links with optional viewerId; unified document name handling for both dataroom and document conversations; added linkType prop to distinguish conversation types.
Conversation API & Service
ee/features/conversations/api/conversations-route.ts, ee/features/conversations/lib/api/conversations/index.ts
Introduced linkId query and body support alongside dataroomId; added anonymous viewer creation for document links; made dataroomId optional in conversation creation; conditional notification dispatch based on dataroom presence.
Document Conversation Pages
ee/features/conversations/pages/document-conversation-detail.tsx, ee/features/conversations/pages/document-conversation-overview.tsx, pages/documents/[id]/conversations/[conversationId]/index.tsx, pages/documents/[id]/conversations/index.tsx
Created new pages for document-scoped conversation viewing and management with two-pane layout, message marking, search filtering, and conversation deletion flows.
Document Conversation API Routes
pages/api/teams/[teamId]/documents/[id]/conversations/index.ts, pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts, pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts, pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts
Added new API routes for document-scoped conversation retrieval, creation, message posting, and read status marking with authentication and access validation.
Document Pages
pages/documents/[id]/index.tsx, pages/documents/[id]/settings.tsx
Added dynamic conversation data fetching and NavMenu rendering on document pages; refactored header structure to wrap navigation components.
Link Data & Metadata
lib/api/links/link-data.ts, pages/api/links/[id]/index.ts
Extended link data selection to include id and enableConversation fields for document links.
Schema & Configuration
prisma/schema/conversation.prisma, ee/limits/server.ts
Made dataroomId optional in Conversation model; improved fallback logic for conversationsInDataroom limit handling to preserve plan defaults.

Possibly related PRs

Pre-merge checks

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'updates for conversations' is vague and generic—it uses non-descriptive phrasing that doesn't convey the specific nature of the substantial changes, such as support for document link conversations, anonymous viewers, or API enhancements. Consider using a more specific title that highlights the main feature, such as 'feat: add document link conversation support with anonymous viewers' or 'feat: enable conversations for document links.'
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
ee/limits/server.ts (2)

110-118: Inconsistent null handling for conversationsInDataroom between plan types.

The free plan path spreads parsedData directly, meaning if parsedData.conversationsInDataroom is explicitly null, it will override the default limits value. However, the paid plan path (lines 120-140) treats null as "not explicitly set" and falls back to the default. This inconsistency could lead to unexpected behavior.

Consider applying the same null-handling logic to the free plan path:

   if (isFreePlan(team.plan)) {
+    const conversationsInDataroom =
+      parsedData.conversationsInDataroom !== null &&
+      parsedData.conversationsInDataroom !== undefined
+        ? parsedData.conversationsInDataroom
+        : (defaultLimits as any).conversationsInDataroom;
+
     return {
       ...defaultLimits,
       ...parsedData,
+      ...(conversationsInDataroom !== undefined && {
+        conversationsInDataroom,
+      }),
       usage: { documents: documentCount, links: linkCount, users: userCount },
       ...(isTrial && {
         users: 3,
       }),
     };

142-157: Inconsistent property presence between success and error paths.

In the paid plan success path (lines 132-134), conversationsInDataroom is only included if defined. However, in this error path, it's always present—either from defaultLimits or defaulting to false. This means the return object shape differs depending on whether parsing succeeds or fails.

Consider matching the conditional inclusion pattern from the success path:

   const defaultLimits = planLimitsMap[basePlan] || FREE_PLAN_LIMITS;
+  const conversationsInDataroom = (defaultLimits as any).conversationsInDataroom ?? false;
+
   return {
     ...defaultLimits,
-    // Preserve conversationsInDataroom from default limits if it exists
-    ...((defaultLimits as any).conversationsInDataroom === undefined && {
-      conversationsInDataroom: false,
-    }),
+    ...(conversationsInDataroom !== undefined && {
+      conversationsInDataroom,
+    }),
     usage: { documents: documentCount, links: linkCount, users: userCount },
components/view/document-view.tsx (1)

31-49: Align DEFAULT_DOCUMENT_VIEW_TYPE and analytics with viewerId usage

You’re now destructuring and storing viewerId, but the exported type and analytics payload haven’t fully caught up:

  1. Type definition is missing viewerId

DEFAULT_DOCUMENT_VIEW_TYPE does not currently declare viewerId, but you pass viewerId into setViewData. This is a type mismatch.

Consider updating the type:

 export type DEFAULT_DOCUMENT_VIEW_TYPE = {
   viewId?: string;
+  viewerId?: string;
   file?: string | null;
   pages?:
     | {
  1. Analytics payload still uses viewId under viewerId key

Now that you have a true viewerId, it’s more accurate to send that (and optionally fall back to viewId):

-          viewerId: viewId,
+          viewerId: viewerId ?? viewId,

That keeps existing behavior but starts emitting a semantically correct viewer identifier when available.

Also applies to: 154-165, 191-201

ee/features/conversations/components/viewer/conversation-view-sidebar.tsx (1)

83-106: SWR cache keys for conversations are inconsistent when viewerId is missing

There’s a subtle but real mismatch between the fetch key and the mutate keys:

  • Fetch key (documents)
    When dataroomId is falsy and viewerId is falsy, useSWR uses:
    `/api/conversations?linkId=${linkId}`
  • Mutate key (documents)
    handleSendMessage always mutates:
    `/api/conversations?linkId=${linkId}&viewerId=${viewerId}`
    which becomes viewerId=undefined when viewerId is falsy and does not match the fetch key.
  • Mutate key (create)
    handleCreateConversation builds a cacheKey that always includes an &viewerId=${effectiveViewerId} suffix. For datarooms with no viewerId yet, the hook’s key is null, so you’re mutating a key this component isn’t subscribed to.

In scenarios where viewerId is not populated (e.g., preview views or any future flow where you deliberately omit viewerId), list views will not update in response to new messages or new conversations, because you’re mutating a different key than the one used by useSWR.

Consider centralizing key construction and reusing it everywhere:

-  } = useSWR<Conversation[]>(
-    dataroomId
-      ? (viewerId
-          ? `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`
-          : null)
-      : `/api/conversations?linkId=${linkId}${viewerId ? `&viewerId=${viewerId}` : ""}`,
+  const conversationsKey =
+    dataroomId
+      ? viewerId
+        ? `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`
+        : null
+      : `/api/conversations?linkId=${linkId}${
+          viewerId ? `&viewerId=${viewerId}` : ""
+        }`;
+
+  } = useSWR<Conversation[]>(
+    conversationsKey,
     fetcher,
     { /* ... */ },
   );

Then in handleCreateConversation and handleSendMessage, mutate the same key (guarding against null):

-      const cacheKey = dataroomId
-        ? `/api/conversations?dataroomId=${dataroomId}&viewerId=${effectiveViewerId}`
-        : `/api/conversations?linkId=${linkId}&viewerId=${effectiveViewerId}`;
-      mutate(
-        cacheKey,
+      const cacheKey = dataroomId
+        ? effectiveViewerId
+          ? `/api/conversations?dataroomId=${dataroomId}&viewerId=${effectiveViewerId}`
+          : null
+        : `/api/conversations?linkId=${linkId}${
+            effectiveViewerId ? `&viewerId=${effectiveViewerId}` : ""
+          }`;
+      if (cacheKey) {
+        mutate(
+          cacheKey,
           [newConversation, ...(conversations || [])],
           false,
-      );
+        );
+      }
-      mutate(
-        dataroomId
-          ? `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`
-          : `/api/conversations?linkId=${linkId}&viewerId=${viewerId}`,
+      if (conversationsKey) {
+        mutate(
+          conversationsKey,
           conversations?.map((conv) =>
             conv.id === activeConversation.id
               ? { /* ... */ }
               : conv,
           ),
           false,
-      );
+        );
+      }

This keeps the cache coherent for both dataroom and document links and avoids divergent keys when viewerId is absent.

Also applies to: 109-152, 192-206

components/links/link-sheet/index.tsx (1)

51-89: Default conversation state for document links may conflict with email auth expectations

With enableConversation now defaulting to true for DOCUMENT_LINK, new document links will start with:

  • enableConversation: true
  • emailProtected: true
  • emailAuthenticated: false

However, ConversationSection explicitly turns both email flags on only when the user enables conversations via the toggle. Because the toggle isn’t invoked on creation, document links can end up with conversations enabled but email authentication still off.

If the product intent is “Q&A conversations require authenticated viewers”, consider aligning the defaults, e.g.:

-  emailAuthenticated: false,
+  emailAuthenticated:
+    linkType === LinkType.DOCUMENT_LINK ? true : false,
...
-  enableConversation: linkType === LinkType.DOCUMENT_LINK ? true : false,
+  enableConversation: linkType === LinkType.DOCUMENT_LINK ? true : false,

or otherwise ensuring that whenever enableConversation is true for document links, emailAuthenticated is also true.

🧹 Nitpick comments (13)
ee/limits/server.ts (2)

120-134: Consider simplifying the type check and improving type safety.

The condition parsedData.conversationsInDataroom !== null && parsedData.conversationsInDataroom !== undefined can be simplified since the schema only allows boolean | null | undefined. Additionally, using as any bypasses type safety.

Consider this refactor:

-      const conversationsInDataroom =
-        parsedData.conversationsInDataroom !== null &&
-        parsedData.conversationsInDataroom !== undefined
-          ? parsedData.conversationsInDataroom
-          : (defaultLimits as any).conversationsInDataroom;
+      const conversationsInDataroom =
+        typeof parsedData.conversationsInDataroom === 'boolean'
+          ? parsedData.conversationsInDataroom
+          : defaultLimits.conversationsInDataroom;

Note: This assumes TPlanLimits type includes conversationsInDataroom?: boolean. If not, you may need to update the type definition instead of using as any.


120-126: Improve type safety by updating TPlanLimits type definition.

The repeated use of (defaultLimits as any).conversationsInDataroom suggests that the TPlanLimits type doesn't include the conversationsInDataroom property, requiring type assertion to bypass checks. This reduces type safety and maintainability.

Update the TPlanLimits type definition in the constants file to include:

conversationsInDataroom?: boolean;

This will eliminate the need for as any casting and provide proper type checking throughout the codebase.

Also applies to: 150-152

pages/api/links/[id]/index.ts (1)

66-66: enableConversation wiring looks correct

Including enableConversation in the GET select and persisting it from linkData on PUT aligns this route with the new conversations feature; the || false default keeps the field explicitly opt‑in.

If you ever evolve this to a tri‑state flag, consider switching to enableConversation: linkData.enableConversation ?? false to avoid treating non‑boolean truthy values as true.

Also applies to: 401-405

lib/api/links/link-data.ts (1)

368-369: Additional fields in document linkData are fine, but watch duplication

Selecting id and enableConversation on the link here is consistent with the new conversations feature and safe, though these fields may already be available from the base link query in /api/links/[id]. If duplication becomes confusing, you could narrow this select to just what consumers truly need.

components/view/nav.tsx (1)

204-228: Conversation toggle behavior and gating look consistent

The updated effect dependencies ensure the keyboard handler respects the latest conversationsEnabled / showConversations values, and the Q&A button + sidebar now correctly key off conversationsEnabled rather than isDataroom.

Note: the 'c' shortcut only opens the sidebar (it doesn’t toggle it closed; Escape does), which matches the inline comment but is worth keeping in mind for UX.

Also applies to: 312-321, 476-487

ee/features/conversations/components/dashboard/conversation-list-item.tsx (1)

20-38: Fallback to conversation.title is now redundant

With formatDocumentReference always returning a non-empty string (falls back to "Untitled conversation"), the || conversation.title at Line 67 will never be used. Consider inlining the fallback there or dropping the || conversation.title for clarity.

pages/documents/[id]/settings.tsx (1)

77-95: Confirm NavMenu active state and intended tab set on Settings page

Wrapping DocumentHeader + NavMenu in a <header> looks good. One thing to double‑check:

  • On /documents/[id]/settings, NavMenu now only renders an “Overview” item with segment: \${document.id}`andhref: /documents/${document.id}`.
  • If NavItem determines the active tab using the last URL segment (e.g., "settings"), this page might show no tab as active.

If the intent is for Settings to be an un-tabbed subpage (only showing a link back to Overview), this is fine; otherwise, consider adding a Settings nav item with a segment that matches what NavItem expects for this route.

ee/features/conversations/components/dashboard/link-option-conversation-section.tsx (1)

3-72: Behavior is good; normalize Data Rooms Plus label for consistency

The new linkType prop and isDocumentLink logic nicely let this section adapt tooltip copy and upgrade plan between document and dataroom links.

Minor nit: requiredPlan uses "data rooms plus" while the upgrade plan uses "Data Rooms Plus", and other plan strings appear to use this capitalized form. For consistency (and to avoid potential string‑union mismatches), consider:

-        requiredPlan={isDocumentLink ? "Pro" : "data rooms plus"}
+        requiredPlan={isDocumentLink ? "Pro" : "Data Rooms Plus"}
...
-            plan: isDocumentLink ? "Pro" : "Data Rooms Plus",
+            plan: isDocumentLink ? "Pro" : "Data Rooms Plus",
pages/documents/[id]/index.tsx (1)

187-195: Unnecessary fragment wrapper.

The empty fragment <>...</> wrapping the conditional AnnotationSheet is redundant since the conditional expression already handles the rendering logic.

-              <>
-                {featureFlags?.annotations && (
-                  <AnnotationSheet
-                    documentId={prismaDocument.id}
-                    teamId={teamId}
-                    numPages={primaryVersion.numPages || 1}
-                  />
-                )}
-              </>,
+              featureFlags?.annotations ? (
+                <AnnotationSheet
+                  key="annotations"
+                  documentId={prismaDocument.id}
+                  teamId={teamId}
+                  numPages={primaryVersion.numPages || 1}
+                />
+              ) : null,
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts (2)

10-10: Unused import: messageService

The messageService is imported but not used in this file.

-import { messageService } from "@/ee/features/conversations/lib/api/messages";

80-92: Redundant database query for link data.

The link data is already included in the conversation query (lines 64-72), but a separate query is made to verify ownership. Consider reusing the included data.

       // Verify conversation belongs to a link of this document
       if (conversation.linkId) {
-        const link = await prisma.link.findUnique({
-          where: { id: conversation.linkId },
-          select: { documentId: true },
-        });
-
-        if (!link || link.documentId !== documentId) {
+        // Use the already-included link data from conversation query
+        // Need to adjust the include to also select documentId
+        const linkDocumentId = conversation.link?.document ? documentId : null;
+        // Or fetch document relationship - for now verify via link existence
+        if (!conversation.link) {
           return res.status(403).json({
             error: "Conversation does not belong to this document",
           });
         }
       }

Alternatively, add documentId to the link select in the original query:

           link: {
             select: {
+              documentId: true,
               document: {
                 select: {
                   name: true,
                 },
               },
             },
           },

Then verify with conversation.link.documentId !== documentId.

ee/features/conversations/pages/document-conversation-detail.tsx (1)

28-28: Unused import: Badge

The Badge component is imported but not used anywhere in this file.

-import { Badge } from "@/components/ui/badge";
pages/api/teams/[teamId]/documents/[id]/conversations/index.ts (1)

117-121: Avoid using any type for better type safety.

Multiple places use explicit any type casts which reduces TypeScript's ability to catch errors. Consider defining proper types or inferring from Prisma.

You can leverage Prisma's generated types:

import { Prisma } from "@prisma/client";

type ConversationWithRelations = Prisma.ConversationGetPayload<{
  include: {
    participants: true;
    messages: { take: 1; select: { content: true; createdAt: true } };
    link: { select: { id: true; document: { select: { id: true; name: true } } } };
    _count: { select: { messages: true } };
  };
}>;

Then use this type instead of any in the map/filter callbacks.

Also applies to: 137-141, 143-163

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01c7812 and a5916da.

📒 Files selected for processing (26)
  • app/api/views/route.ts (2 hunks)
  • components/links/link-sheet/index.tsx (1 hunks)
  • components/links/link-sheet/link-options.tsx (1 hunks)
  • components/navigation-menu.tsx (4 hunks)
  • components/view/document-view.tsx (2 hunks)
  • components/view/nav.tsx (3 hunks)
  • components/view/view-data.tsx (1 hunks)
  • ee/features/conversations/api/conversations-route.ts (6 hunks)
  • ee/features/conversations/components/dashboard/conversation-list-item.tsx (1 hunks)
  • ee/features/conversations/components/dashboard/link-option-conversation-section.tsx (4 hunks)
  • ee/features/conversations/components/viewer/conversation-view-sidebar.tsx (5 hunks)
  • ee/features/conversations/lib/api/conversations/index.ts (3 hunks)
  • ee/features/conversations/pages/document-conversation-detail.tsx (1 hunks)
  • ee/features/conversations/pages/document-conversation-overview.tsx (1 hunks)
  • ee/limits/server.ts (2 hunks)
  • lib/api/links/link-data.ts (1 hunks)
  • pages/api/links/[id]/index.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/conversations/index.ts (1 hunks)
  • pages/documents/[id]/conversations/[conversationId]/index.tsx (1 hunks)
  • pages/documents/[id]/conversations/index.tsx (1 hunks)
  • pages/documents/[id]/index.tsx (4 hunks)
  • pages/documents/[id]/settings.tsx (1 hunks)
  • prisma/schema/conversation.prisma (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
pages/documents/[id]/settings.tsx (2)
components/documents/document-header.tsx (1)
  • DocumentHeader (68-987)
components/navigation-menu.tsx (1)
  • NavMenu (30-59)
ee/features/conversations/pages/document-conversation-overview.tsx (9)
pages/documents/[id]/conversations/index.tsx (1)
  • DocumentConversationOverviewPage (3-5)
lib/swr/use-document.ts (1)
  • useDocument (11-55)
lib/utils.ts (1)
  • fetcher (48-62)
components/documents/document-header.tsx (1)
  • DocumentHeader (68-987)
components/navigation-menu.tsx (1)
  • NavMenu (30-59)
components/ui/badge.tsx (1)
  • Badge (50-50)
ee/features/conversations/components/dashboard/conversation-list-item.tsx (1)
  • ConversationListItem (7-82)
components/ui/dialog.tsx (6)
  • Dialog (149-149)
  • DialogContent (151-151)
  • DialogHeader (152-152)
  • DialogTitle (154-154)
  • DialogDescription (155-155)
  • DialogFooter (153-153)
components/ui/button.tsx (1)
  • Button (71-71)
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts (2)
lib/types.ts (1)
  • CustomUser (17-17)
ee/features/conversations/lib/api/messages/index.ts (1)
  • messageService (6-160)
pages/documents/[id]/conversations/index.tsx (1)
ee/features/conversations/pages/document-conversation-overview.tsx (1)
  • DocumentConversationOverviewPage (55-316)
pages/documents/[id]/conversations/[conversationId]/index.tsx (1)
ee/features/conversations/pages/document-conversation-detail.tsx (1)
  • DocumentConversationDetailPage (83-489)
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts (4)
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts (1)
  • handler (13-103)
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts (1)
  • handler (11-82)
pages/api/teams/[teamId]/documents/[id]/conversations/index.ts (1)
  • handler (14-235)
lib/types.ts (1)
  • CustomUser (17-17)
components/navigation-menu.tsx (1)
components/sidebar/nav-main.tsx (1)
  • NavItem (29-44)
components/links/link-sheet/link-options.tsx (1)
ee/features/conversations/components/dashboard/link-option-conversation-section.tsx (1)
  • ConversationSection (9-77)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (12)
components/navigation-menu.tsx (1)

17-26: Count badge addition is clean and backwards‑compatible

The new optional count prop is threaded correctly through NavMenuNavItem, and the rendering logic (positive count → numeric badge, else fall back to tag) preserves existing behavior for callers that don’t provide a count.

Also applies to: 40-49, 61-69, 127-135

components/links/link-sheet/link-options.tsx (1)

275-301: Link-type-aware conversations wiring looks correct

Passing linkType into ConversationSection for dataroom links and adding a dedicated “Q&A Conversations” section for DOCUMENT_LINK cleanly aligns the UI with the new link-type‑aware behavior and plan gating. The isAllowed expression also matches the intended set of paid plans that can toggle this feature.

pages/documents/[id]/conversations/[conversationId]/index.tsx (1)

1-5: Thin wrapper page is appropriate

This page cleanly re-exports the existing DocumentConversationDetail component under the document/conversation route without adding extra logic. Looks good.

prisma/schema/conversation.prisma (1)

10-12: Making dataroom relation optional is consistent with document-scoped conversations

Allowing dataroomId/dataroom to be nullable fits the new use cases where conversations are anchored to documents or links without a dataroom. Keeping onDelete: Cascade and the index is appropriate.

pages/documents/[id]/conversations/index.tsx (1)

1-5: Wrapper page cleanly exposes the document conversations overview

This correctly wires the shared DocumentConversationOverview feature into the /documents/[id]/conversations route without extra complexity.

ee/features/conversations/lib/api/conversations/index.ts (1)

54-54: LGTM! Clean implementation for optional dataroomId.

The conditional spread pattern correctly handles the optional dataroomId for both the data payload and the include clause, enabling document-only conversations without dataroom association.

Also applies to: 73-73, 111-111

pages/documents/[id]/index.tsx (2)

91-110: LGTM! Well-configured SWR hook for conversations.

The SWR configuration with revalidateOnFocus, dedupingInterval, and keepPreviousData is appropriate for this use case. The conditional fetching based on prismaDocument?.id && teamId prevents unnecessary requests.


209-225: LGTM! Conditional NavMenu rendering with unread badge.

The NavMenu is correctly rendered only when conversations exist, and the unread count badge logic (unreadCount > 0 ? unreadCount : undefined) properly hides the badge when there are no unread messages.

pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts (1)

11-29: LGTM! Proper authentication and parameter extraction.

The session-based authentication and query parameter extraction are correctly implemented.

ee/features/conversations/pages/document-conversation-overview.tsx (1)

70-85: LGTM! Well-configured SWR hook with error handling.

The SWR configuration includes appropriate error handling with toast notification, and the keepPreviousData option provides a smooth UX during refetches.

pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts (1)

86-93: LGTM! Correct async notification pattern with waitUntil.

Using waitUntil to trigger the notification task asynchronously is the correct pattern for Vercel serverless functions, allowing the response to return immediately while the notification is processed in the background.

ee/features/conversations/api/conversations-route.ts (1)

276-297: Notifications are only sent for dataroom conversations.

Document link conversations (where dataroomId is absent) will not trigger team member notifications. Verify this is the intended behavior, or add notification support for link-based conversations.

Also applies to: 334-355

Comment on lines 470 to 519
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");
}
}
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.

Comment on lines +87 to 88
viewerId: viewData.viewerId || ("viewerId" in viewData ? viewData.viewerId : undefined),
isMobile: isMobile,
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.

Comment on lines +158 to +163
team = await prisma.team.findUnique({
where: { id: link.teamId || link.document?.teamId },
select: {
id: true,
},
},
select: {
id: true,
},
});
});
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

Potential undefined value in findUnique where clause.

If both link.teamId and link.document?.teamId are undefined/null, the findUnique query will use undefined as the id, which may cause unexpected behavior.

+      const teamId = link.teamId || link.document?.teamId;
+      if (!teamId) {
+        return res.status(400).json({ error: "Team not found for link" });
+      }
+
       team = await prisma.team.findUnique({
-        where: { id: link.teamId || link.document?.teamId },
+        where: { id: teamId },
         select: {
           id: true,
         },
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
team = await prisma.team.findUnique({
where: { id: link.teamId || link.document?.teamId },
select: {
id: true,
},
},
select: {
id: true,
},
});
});
const teamId = link.teamId || link.document?.teamId;
if (!teamId) {
return res.status(400).json({ error: "Team not found for link" });
}
team = await prisma.team.findUnique({
where: { id: teamId },
select: {
id: true,
},
});
🤖 Prompt for AI Agents
In ee/features/conversations/api/conversations-route.ts around lines 158-163,
the call to prisma.team.findUnique uses link.teamId || link.document?.teamId
which can be undefined; before calling findUnique validate that a concrete
teamId exists and handle the missing case (e.g., return an error/early-exit or
throw with a clear message), or alternatively change the lookup to a safer query
that doesn't receive undefined (e.g., guard the code path and only call
findUnique when teamId is truthy). Ensure you do not pass undefined into the
where clause and add a clear error/handling path for missing teamId.

Comment on lines +170 to +190
// For document links, create anonymous viewer if viewerId is not provided
if (!viewerId && !dataroomId) {
// Get IP address from request for anonymous viewer creation
const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;

// Create anonymous viewer
const anonymousViewer = await prisma.viewer.create({
data: {
email: anonymousEmail,
verified: false,
teamId: team.id,
},
select: { id: true },
});

// Use the anonymous viewer ID
viewerId = anonymousViewer.id;
} else if (!viewerId && dataroomId) {
return res.status(400).json({ error: "Viewer is required" });
}
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 | 🟠 Major

Consider privacy implications of storing IP in email field.

Storing the client IP address in the email field could raise privacy/compliance concerns (GDPR, CCPA). Consider using a hash or random identifier instead.

-      const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
-      const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;
+      // Use a random identifier instead of IP to avoid storing PII
+      const randomId = crypto.randomUUID().slice(0, 8);
+      const anonymousEmail = `anonymous-${randomId}-${Date.now()}@anonymous.papermark.com`;

You'll need to import crypto from Node.js:

import crypto from "crypto";
🤖 Prompt for AI Agents
In ee/features/conversations/api/conversations-route.ts around lines 170 to 190,
avoid embedding the raw client IP into the anonymous email (privacy risk);
instead generate a non-identifying identifier by hashing the IP (or using a
random UUID) and use that hash/ID in the anonymous email or as a separate
identifier; import Node's crypto module (import crypto from "crypto") and use
crypto.createHash('sha256').update(String(ipAddress)).digest('hex') (or
crypto.randomUUID()) to produce the opaque token, then construct the anonymous
email with that token rather than the raw IP and continue creating the viewer
with the token-based email.

Comment on lines +173 to +174
const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;
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

x-forwarded-for header may contain multiple IP addresses.

The x-forwarded-for header can contain a comma-separated list of IPs. Extract only the client IP (first in the list) for consistent behavior.

-      const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
-      const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;
+      const forwardedFor = req.headers["x-forwarded-for"];
+      const clientIp = (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : forwardedFor?.[0]) 
+        || req.headers["x-real-ip"] 
+        || req.socket.remoteAddress 
+        || "unknown";
+      const anonymousEmail = `anonymous-${String(clientIp).replace(/[.:]/g, '-')}-${Date.now()}@anonymous.papermark.com`;

Note: Also handle IPv6 colons in the replacement pattern.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;
const forwardedFor = req.headers["x-forwarded-for"];
const clientIp = (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : forwardedFor?.[0])
|| req.headers["x-real-ip"]
|| req.socket.remoteAddress
|| "unknown";
const anonymousEmail = `anonymous-${String(clientIp).replace(/[.:]/g, '-')}-${Date.now()}@anonymous.papermark.com`;
🤖 Prompt for AI Agents
In ee/features/conversations/api/conversations-route.ts around lines 173-174,
the code currently reads the x-forwarded-for header as a whole and uses it in
the anonymous email; update it to parse x-forwarded-for as a comma-separated
list, take the first entry (trim whitespace) as the client IP, fall back to
x-real-ip / req.socket.remoteAddress / "unknown" if missing, and then sanitize
that single IP for the email by replacing dots and colons (e.g. use a regex to
replace /[.:]/g with '-') so IPv4 and IPv6 addresses are normalized
consistently.

Comment on lines +81 to +92
if (conversation.linkId) {
const link = await prisma.link.findUnique({
where: { id: conversation.linkId },
select: { documentId: true },
});

if (!link || link.documentId !== documentId) {
return res.status(403).json({
error: "Conversation does not belong to this document",
});
}
}
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

🧩 Analysis chain

🏁 Script executed:

# Find Prisma schema files
fd -t f "schema.prisma"

Repository: mfts/papermark

Length of output: 85


🏁 Script executed:

# Search for the Conversation model definition
rg -A 20 "model Conversation"

Repository: mfts/papermark

Length of output: 4583


🏁 Script executed:

# Read the file to see the full context of this endpoint
cat -n pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts | head -100

Repository: mfts/papermark

Length of output: 3463


🏁 Script executed:

# Search for conversation creation to understand if linkId should always be set
rg -A 5 "conversation.*create" --type ts -i | head -80

Repository: mfts/papermark

Length of output: 6820


🏁 Script executed:

# Check if there are other endpoints or code that validates conversation-document relationship differently
rg "conversation.*linkId\|dataroomDocumentId" --type ts -B 2 -A 2 | head -100

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

# Search for other conversation endpoints to see how they validate access
rg "conversation.*findUnique\|conversation.*findMany" --type ts -B 3 -A 10 | grep -A 10 "documentId\|linkId\|dataroomDocumentId" | head -120

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

# Check the conversations list endpoint to see how it filters conversations
cat -n pages/api/teams/[teamId]/documents/[id]/conversations/index.ts | head -80

Repository: mfts/papermark

Length of output: 2805


🏁 Script executed:

# Verify the relationship between conversations and dataroomDocument in the context
rg "dataroomDocumentId" prisma/schema/conversation.prisma -B 2 -A 2

Repository: mfts/papermark

Length of output: 948


Conversations without a linkId bypass document access verification.

The endpoint filters conversations by verifying conversation.linkId belongs to the requested document only when linkId is present. If linkId is null, any conversation can be accessed regardless of whether it belongs to this document. Since conversations can be linked via linkId (document link), dataroomDocumentId (dataroom document), or neither (team-level), the verification must either reject conversations without a linkId or validate the appropriate relationship. This is also inconsistent with the list endpoint, which only returns conversations linked to the document's links.

Comment on lines +65 to +77
// Verify conversation belongs to a link of this document
if (conversation.linkId) {
const link = await prisma.link.findUnique({
where: { id: conversation.linkId },
select: { documentId: true },
});

if (!link || link.documentId !== documentId) {
return res.status(403).json({
error: "Conversation does not belong to this document",
});
}
}
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 | 🟠 Major

Same authorization gap as read endpoint when conversation has no linkId.

Similar to the read endpoint, when conversation.linkId is null, no validation ensures the conversation belongs to this document. This could allow creating messages in conversations through unrelated document endpoints.

Apply the same fix as suggested for the read endpoint to ensure consistent authorization.

🤖 Prompt for AI Agents
In
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts
around lines 65 to 77, the current check only validates the conversation when
conversation.linkId is present and leaves an authorization gap when linkId is
null; update the logic to also validate that the conversation belongs to the
document by comparing conversation.documentId to documentId when linkId is falsy
(or always fetch conversation.documentId and use it when linkId is absent), and
return 403 if the conversation.documentId does not match the documentId so the
same ownership check used in the read endpoint is applied here.

Comment on lines +79 to +84
// Create the message
const message = await messageService.createMessage({
conversationId,
content: content.trim(),
userId,
});
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

🧩 Analysis chain

🏁 Script executed:

# Search for messageService exports and method definitions in the features directory
rg -n "export.*messageService|createMessage|addMessage" --type ts ee/features/conversations/lib/api/messages/

Repository: mfts/papermark

Length of output: 218


🏁 Script executed:

# Also check the actual file being reviewed to see how messageService is imported and used
rg -n "messageService\." pages/api/teams/\[teamId\]/documents/\[id\]/conversations/\[conversationId\]/messages/index.ts

Repository: mfts/papermark

Length of output: 119


Change createMessage to addMessage at line 80.

The code calls messageService.createMessage, but the messageService only exports addMessage. This will cause a runtime error. Update the method call to match the actual export.

🤖 Prompt for AI Agents
In
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts
around lines 79 to 84, the call uses messageService.createMessage but the
service exports addMessage; replace the call to messageService.addMessage so it
matches the actual export and pass the same payload (conversationId, content:
content.trim(), userId); verify any surrounding references or tests expecting
createMessage are updated to use addMessage as well.

Comment on lines +57 to +69
// Verify conversation belongs to a link of this document
if (conversation.linkId) {
const link = await prisma.link.findUnique({
where: { id: conversation.linkId },
select: { documentId: true },
});

if (!link || link.documentId !== documentId) {
return res.status(403).json({
error: "Conversation does not belong to this document",
});
}
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's read the full file to understand the complete context
wc -l "pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts"

Repository: mfts/papermark

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Read the file to see full context
cat -n "pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts"

Repository: mfts/papermark

Length of output: 2940


🏁 Script executed:

#!/bin/bash
# Search for Conversation model/type definition
rg -n "type Conversation|interface Conversation" --type ts

Repository: mfts/papermark

Length of output: 1210


🏁 Script executed:

#!/bin/bash
# Search for how conversations are fetched and validated
rg -n "prisma\.conversation" --type ts -A 3 -B 1

Repository: mfts/papermark

Length of output: 12543


🏁 Script executed:

#!/bin/bash
# Check if there are other relationship checks for conversations and documents
rg -n "linkId" --type ts -B 2 -A 2

Repository: mfts/papermark

Length of output: 50371


🏁 Script executed:

#!/bin/bash
# Check the conversation creation logic to see if linkId is optional
rg -n "prisma.conversation.create" --type ts -A 10 -B 2 | head -100

Repository: mfts/papermark

Length of output: 1370


🏁 Script executed:

#!/bin/bash
# Check the Conversation model definition in schema
find . -name "schema.prisma" -o -name "*.prisma" | xargs cat 2>/dev/null | grep -A 20 "model Conversation"

Repository: mfts/papermark

Length of output: 2441


🏁 Script executed:

#!/bin/bash
# Check if there are other validation checks for dataroomId conversations
rg -n "dataroomId" "pages/api/teams/\[teamId\]/documents/\[id\]/conversations/\[conversationId\]/read/index.ts"

Repository: mfts/papermark

Length of output: 188


🏁 Script executed:

#!/bin/bash
# Check similar endpoints to see if they have the same issue
cat -n "pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/messages/index.ts" | sed -n '55,75p'

Repository: mfts/papermark

Length of output: 890


🏁 Script executed:

#!/bin/bash
# Verify the issue exists in the DELETE endpoint too
cat -n "pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/index.ts" | sed -n '155,180p'

Repository: mfts/papermark

Length of output: 1024


Add validation for conversations without a linkId to prevent authorization bypass.

Conversations can legitimately have linkId as null (for dataroom-scoped conversations). When a conversation lacks a linkId, the current code skips validation entirely and proceeds to mark it as read, which bypasses authorization. This allows users with access to a document to modify conversations from other datarooms in the same team.

The fix should explicitly reject conversations without a linkId:

      // Verify conversation belongs to a link of this document
      if (conversation.linkId) {
        const link = await prisma.link.findUnique({
          where: { id: conversation.linkId },
          select: { documentId: true },
        });

        if (!link || link.documentId !== documentId) {
          return res.status(403).json({
            error: "Conversation does not belong to this document",
          });
        }
+      } else {
+        // Conversations without linkId should not be accessible from document endpoints
+        return res.status(403).json({
+          error: "Conversation does not belong to this document",
+        });
      }

This same pattern exists in the GET detail, GET messages, and DELETE endpoints for conversations—all need the same fix.

🤖 Prompt for AI Agents
pages/api/teams/[teamId]/documents/[id]/conversations/[conversationId]/read/index.ts
around lines 57-69: the current branch only validates conversations that have a
linkId, allowing conversations with null linkId to skip authorization and be
marked read (authorization bypass). Update the logic to explicitly reject
conversations with a null linkId (return 403 or equivalent error) when the
endpoint expects a document-scoped conversation; keep the existing
link.documentId check for non-null linkId. Apply the same explicit null-linkId
rejection in the corresponding GET detail, GET messages, and DELETE conversation
endpoints to prevent the same bypass.

Comment on lines +216 to +227
// Create the conversation
const conversation = await conversationService.createConversation({
viewId,
userId,
data: {
...data,
linkId,
},
teamId,
});

return res.status(201).json(conversation);
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

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "*.ts" -o -name "*.js" | xargs rg -l "conversationService|createConversation" | head -20

Repository: mfts/papermark

Length of output: 641


🏁 Script executed:

ast-grep --pattern 'createConversation($_) {
  $$$
}'

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

rg "createConversation" -A 15 --type ts --type js

Repository: mfts/papermark

Length of output: 5430


🏁 Script executed:

cat -n pages/api/teams/[teamId]/documents/[id]/conversations/index.ts | sed -n '180,230p'

Repository: mfts/papermark

Length of output: 1741


🏁 Script executed:

cat -n ee/features/conversations/lib/api/conversations/index.ts | sed -n '1,80p'

Repository: mfts/papermark

Length of output: 2795


🏁 Script executed:

cat -n pages/api/teams/[teamId]/documents/[id]/conversations/index.ts | sed -n '1,180p'

Repository: mfts/papermark

Length of output: 6394


🏁 Script executed:

rg "userId" ee/features/conversations/api/conversations-route.ts -B 5 -A 5 | head -60

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

cat -n ee/features/conversations/api/conversations-route.ts | sed -n '1,100p'

Repository: mfts/papermark

Length of output: 3669


🏁 Script executed:

rg "createConversation" -B 10 ee/features/conversations/api/ -A 3

Repository: mfts/papermark

Length of output: 2392


🏁 Script executed:

cat -n ee/features/conversations/api/team-conversations-route.ts | sed -n '1,150p'

Repository: mfts/papermark

Length of output: 5490


🏁 Script executed:

rg "viewerId" pages/api/teams/[teamId]/documents/[id]/conversations/index.ts -B 3 -A 3

Repository: mfts/papermark

Length of output: 1476


Remove unused viewerId parameter from request body handling.

The viewerId is extracted from the request body at line 192 but never passed to conversationService.createConversation. Since this is a team member endpoint (authenticated via session), conversations are created with userId instead. Either remove the viewerId destructuring to clarify the endpoint only accepts team member conversations, or pass viewerId if the endpoint should support viewer-based conversations.

🤖 Prompt for AI Agents
In pages/api/teams/[teamId]/documents/[id]/conversations/index.ts around lines
216-227 (viewerId was destructured from req.body at line ~192 but never used),
remove the unused viewerId destructuring from the request body and any related
references so the handler only uses the authenticated userId when calling
conversationService.createConversation; if instead you intended to support
viewer-based conversations, pass viewerId into the createConversation payload
(or validate it) — but default fix: delete the viewerId extraction to avoid
unused variable and clarify the endpoint only creates team-member conversations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants