Skip to content

Conversation

@mfts
Copy link
Owner

@mfts mfts commented Sep 15, 2025

Summary by CodeRabbit

  • New Features
    • Add document annotations: create/edit/delete with rich-text (image uploads), page selection, visibility toggle, management UI, nav toggle, and an optional resizable viewer panel with per-page previews.
  • Feature Flags
    • Team-level gating for annotations; viewers only receive visible annotations; per-view TTL enforced.
  • Backend / API
    • New authenticated team APIs and public viewer endpoints for listing, creating, updating, deleting annotations and uploading annotation images.
  • Database
    • Migration and schema additions for annotations and annotation images.
  • Chores
    • Added editor, form, and resizable-panel dependencies.

@vercel
Copy link

vercel bot commented Sep 15, 2025

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

Project Deployment Preview Comments Updated (UTC)
papermark Ready Ready Preview Comment Sep 15, 2025 5:51pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 15, 2025

Walkthrough

Adds end-to-end document annotations: DB models + migration, Prisma schema relations, server APIs for team CRUD and link/view reads, SWR hooks, TipTap rich-text editor and form UI, admin AnnotationSheet, viewer AnnotationPanel/Toggle, resizable viewer integration, feature-flagging, and page wiring.

Changes

Cohort / File(s) Summary
DB schema & migration
prisma/schema/annotation.prisma, prisma/schema/document.prisma, prisma/schema/schema.prisma, prisma/schema/team.prisma, prisma/migrations/20250915000000_add_annotations/migration.sql
Adds DocumentAnnotation and AnnotationImage models, relations to Document/Team/User, indices, migration SQL, and connects annotations to existing models.
Server APIs — team authoring
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts, .../[annotationId].ts, .../[annotationId]/images.ts
Authenticated CRUD and image upload endpoints with access checks, Zod validation, structured logging, and related-data includes (images, createdBy).
Server APIs — viewer reads
pages/api/links/[id]/annotations.ts, pages/api/links/[id]/documents/[documentId]/annotations.ts
Read-only endpoints returning sanitized visible annotations for links/views with TTL enforcement and feature-flag gating.
SWR hooks & types
lib/swr/use-annotations.ts
New Annotation interface and hooks: useAnnotations(documentId, teamId) and useViewerAnnotations(linkId, documentId?, viewId?).
Authoring UI
components/documents/annotations/annotation-form.tsx, components/documents/annotations/annotation-sheet.tsx
New AnnotationForm (react-hook-form + zod, TipTap content, image upload, POST/PUT) and AnnotationSheet managing list/create/edit/delete flows and UI dialogs/sheet.
Viewer UI & Nav
components/view/annotations/annotation-panel.tsx, components/view/annotations/annotation-toggle.tsx, components/view/nav.tsx, components/view/document-view.tsx, components/view/view-data.tsx
Adds AnnotationPanel (per-page cards), AnnotationToggle (nav button + tooltip), and threads annotationsEnabled/hasAnnotations/onToggleAnnotations through nav/view props and types.
Viewer layouts (resizable)
components/view/viewer/pages-horizontal-viewer.tsx, components/view/viewer/pages-vertical-viewer.tsx, components/ui/resizable.tsx
Introduces ResizablePanelGroup/Panel/Handle and converts viewers to optional two-pane horizontal resizable layout that conditionally shows the annotation panel.
Rich text & form primitives
components/ui/rich-text-editor.tsx, components/ui/form-hook.tsx
Adds TipTap-based RichTextEditor (image handling, toolbar) and react-hook-form composition utilities (Form, FormField, useFormField, FormItem, etc.).
Pages wiring & feature flags
pages/documents/[id]/index.tsx, pages/view/[linkId]/index.tsx, lib/featureFlags/index.ts
Adds annotations to BetaFeatures/teamFeatures, wires AnnotationSheet into document header actions, threads annotationsEnabled through view getStaticProps and page props.
Deps & minor
package.json, components/view/viewer/pdf-default-viewer.tsx
Adds TipTap, react-hook-form, resolvers, react-resizable-panels deps; minor formatting tweak in pdf-default-viewer.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Author
  participant Sheet as AnnotationSheet
  participant Form as AnnotationForm
  participant API as /api/teams
  participant DB as Database

  Author->>Sheet: Open manager (Sheet/Dialog)
  Sheet->>Form: Render form (create/edit)
  Author->>Form: Submit title/pages/content/isVisible + images
  Form->>API: POST/PUT /api/teams/:teamId/documents/:id/annotations[/:annotationId]
  API->>DB: Create/Update annotation and images
  DB-->>API: Saved annotation
  API-->>Form: 201/200 OK
  Form-->>Sheet: onSuccess -> close
  Sheet->>API: GET /api/teams/:teamId/documents/:id/annotations (mutate)
  API-->>Sheet: annotations list
Loading
sequenceDiagram
  autonumber
  actor Viewer
  participant Nav as Nav (AnnotationToggle)
  participant ViewerComp as Pages Viewer
  participant SWR as useViewerAnnotations
  participant API as /api/links
  participant Panel as AnnotationPanel

  Viewer->>Nav: Toggle annotations
  Nav-->>ViewerComp: onToggle(enabled)
  ViewerComp->>SWR: fetch annotations (if enabled)
  SWR->>API: GET /api/links/:id[/documents/:docId]/annotations?viewId=...
  API-->>SWR: visible annotations (sanitized)
  SWR-->>ViewerComp: annotations
  ViewerComp->>Panel: render annotations for currentPage
  Panel-->>Viewer: show per-page cards/images
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "feat: add annotations" is concise and accurately reflects the primary change set — introducing annotation support across UI components, API endpoints, SWR hooks, feature flags, and the database schema/migration. It follows conventional commit style and gives a clear, high-level indication of the feature being added without unnecessary detail. This makes it suitable for teammates scanning PR history to understand the main intent.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/annotations

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.

@socket-security
Copy link

socket-security bot commented Sep 15, 2025

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​tiptap/​extension-placeholder@​3.4.21001006799100
Added@​tiptap/​extension-image@​3.4.21001007399100
Added@​tiptap/​starter-kit@​3.4.2981007499100
Added@​tiptap/​react@​3.4.2991007799100
Added@​hookform/​resolvers@​5.2.210010010094100

View full report

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: 11

🧹 Nitpick comments (42)
components/view/viewer/pdf-default-viewer.tsx (1)

314-314: Unify tracking options; avoid calling getTrackingOptions() in render.

Use the same (memoized) options object you pass to the tracker to prevent drift if overrides are introduced and to avoid repeated calls during render.

Apply this diff for the changed line:

-          inactivityThreshold={getTrackingOptions().inactivityThreshold}
+          inactivityThreshold={trackingOptions.inactivityThreshold}

Add and reuse a memoized options object elsewhere in the component:

// inside PDFViewer
const trackingOptions = useMemo(() => getTrackingOptions(), []);
const {
  trackPageViewSafely,
  resetTrackingState,
  startIntervalTracking,
  stopIntervalTracking,
  getActiveDuration,
  isInactive,
  updateActivity,
} = useSafePageViewTracker({
  ...trackingOptions,
  externalStartTimeRef: startTimeRef,
});

Please confirm TRACKING_CONFIG doesn’t change at runtime; if it can, we can memoize based on a version/flag.

lib/featureFlags/index.ts (1)

12-14: Guard against unknown flags from edge-config.

Avoid polluting teamFeatures if betaFeatures contains unexpected keys.

Apply:

-  if (betaFeatures) {
-    for (const [featureFlag, teamIds] of Object.entries(betaFeatures)) {
-      if (teamIds.includes(teamId)) {
-        teamFeatures[featureFlag as BetaFeatures] = true;
-      }
-    }
-  }
+  if (betaFeatures) {
+    for (const [featureFlag, teamIds] of Object.entries(betaFeatures)) {
+      if ((featureFlag as string) in teamFeatures && teamIds.includes(teamId)) {
+        teamFeatures[featureFlag as BetaFeatures] = true;
+      }
+    }
+  }

Also applies to: 29-31, 50-56

components/ui/resizable.tsx (3)

30-36: Missing after:content-[""] prevents the pseudo-element line from rendering; add an accessible label.

Without content, the ::after pseudo doesn’t render. Also add a default aria-label for the handle.

Apply:

   <ResizablePrimitive.PanelResizeHandle
-    className={cn(
-      "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
-      className,
-    )}
-    {...props}
+    className={cn(
+      "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:content-[''] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
+      className,
+    )}
+    aria-label="Resize"
+    {...props}
   >

8-19: Consider forwardRef for PanelGroup/Handle for imperative APIs.

If consumers need programmatic control, expose refs.

I can provide a ref-friendly wrapper if desired.

Also applies to: 21-23, 45-45


37-41: Touch target is quite small for mouse/touch.

Optionally increase hit area (e.g., add p-1 on the handle or min-w-[8px]/min-h-[8px] with transparent background) to improve usability without changing the visual line.

pages/api/links/[id]/documents/[documentId]/annotations.ts (2)

66-92: Dataroom path LGTM; consider narrowing selected fields to minimize payload.

Limit included fields on nested document.annotations.images to only what the client needs (e.g., id, url, width, height) to reduce overexposure.


100-108: Sanitization is good; tighten image shape explicitly.

If AnnotationImage contains internal fields (team/user IDs, storage keys), map to a public DTO shape.

Example:

- images: annotation.images,
+ images: annotation.images.map(({ id, url, width, height }) => ({ id, url, width, height })),
pages/documents/[id]/index.tsx (1)

96-104: Fix potential React key warning and remove unnecessary Fragment.

Give the conditional element a stable key and drop the wrapper fragment inside the actions array.

Apply:

-                <>
-                  {featureFlags?.annotations && (
-                    <AnnotationSheet
-                      documentId={prismaDocument.id}
-                      teamId={teamInfo?.currentTeam?.id!}
-                      numPages={primaryVersion.numPages || 1}
-                    />
-                  )}
-                </>,
+                {featureFlags?.annotations && (
+                  <AnnotationSheet
+                    key={"annotation-sheet"}
+                    documentId={prismaDocument.id}
+                    teamId={teamInfo?.currentTeam?.id!}
+                    numPages={primaryVersion.numPages || 1}
+                  />
+                )},
components/view/annotations/annotation-toggle.tsx (1)

29-36: Add basic a11y: aria-pressed and aria-label on the toggle button.

Improves screen-reader UX without behavioral changes.

           <Button
             variant="ghost"
             size="sm"
             onClick={() => onToggle(!enabled)}
             className={`${
               enabled ? "bg-muted text-foreground" : "text-muted-foreground"
             } ${!hasAnnotations ? "cursor-not-allowed opacity-50" : ""}`}
             disabled={!hasAnnotations}
+            aria-pressed={enabled}
+            aria-label={
+              !hasAnnotations
+                ? "No annotations available"
+                : enabled
+                  ? "Hide annotations"
+                  : "Show annotations"
+            }
           >
pages/api/links/[id]/annotations.ts (2)

61-70: Avoid logic drift with the dataroom-specific endpoint.

This endpoint partially duplicates pages/api/links/[id]/documents/[documentId]/annotations.ts. Extract a shared helper (e.g., getSanitizedAnnotations({ link, documentId? })) to keep behavior consistent and tested once.


72-80: Sanitize image fields before returning to viewers.

Returning full AnnotationImage objects may leak internal fields. Map to a minimal shape (id, url, filename).

-    const sanitizedAnnotations = annotations.map((annotation) => ({
+    const sanitizedAnnotations = annotations.map((annotation) => ({
       id: annotation.id,
       title: annotation.title,
       content: annotation.content,
       pages: annotation.pages,
-      images: annotation.images,
+      images: annotation.images.map((img) => ({
+        id: img.id,
+        url: img.url,
+        filename: img.filename,
+      })),
       createdAt: annotation.createdAt,
     }));

If AnnotationImage has other safe-to-expose fields (e.g., width/height), include them explicitly.

prisma/schema/schema.prisma (1)

68-68: Confirm relation name & delete behavior for User ↔ DocumentAnnotation

  • DocumentAnnotation.createdBy already has onDelete: Cascade (prisma/schema/annotation.prisma:10-11).
  • User.createdAnnotations is an implicit back-relation (prisma/schema/schema.prisma:68). If you want to retain annotations when a user is deleted, make createdById nullable (String?) and use onDelete: SetNull; otherwise Cascade is correct. Add a named @relation on both sides to avoid ambiguity if you later add other User relations (e.g., lastEditedBy).
components/documents/annotations/annotation-form.tsx (4)

28-36: Tighten validation and normalize payload before submit.

  • content: z.any() disables validation; define a minimal TipTap JSON shape or at least unknown/object.
  • pages: enforce bounds [1..numPages] client-side before sending to prevent bad payloads if UI gets out of sync.

Apply this diff:

-const formSchema = z.object({
-  title: z
-    .string()
-    .min(1, "Title is required")
-    .max(100, "Title must be less than 100 characters"),
-  content: z.any().optional(),
-  pages: z.array(z.number()).min(1, "At least one page must be selected"),
-  isVisible: z.boolean().default(true),
-});
+const TipTapJson = z
+  .object({
+    type: z.string().optional(),
+    content: z.array(z.any()).optional(),
+  })
+  .passthrough(); // be permissive but not 'any'
+
+const formSchema = z.object({
+  title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters"),
+  content: TipTapJson.nullable().optional(),
+  pages: z.array(z.number()).min(1, "At least one page must be selected"),
+  isVisible: z.boolean().default(true),
+});
-      const response = await fetch(url, {
+      const payload = {
+        ...values,
+        // defense-in-depth: dedupe and bound-check pages
+        pages: Array.from(new Set(values.pages)).filter((p) => p >= 1 && p <= numPages),
+        content: editorContent,
+      };
+      const response = await fetch(url, {
         method,
         headers: {
           "Content-Type": "application/json",
         },
-        body: JSON.stringify({
-          ...values,
-          content: editorContent,
-        }),
+        body: JSON.stringify(payload),
       });

Also applies to: 96-106


160-170: Guard Checkbox state to boolean to avoid radix “indeterminate”.

Radix’s onCheckedChange can deliver "indeterminate". Coerce to boolean before branching.

-                      onCheckedChange={(checked) => {
-                        if (checked) {
+                      onCheckedChange={(checked) => {
+                        const isChecked = checked === true;
+                        if (isChecked) {
                           field.onChange([...field.value, page]);
                         } else {
                           field.onChange(field.value.filter((p) => p !== page));
                         }
                       }}

72-85: Image uploads bypass the new annotation images API. Clarify expected persistence.

Right now images are uploaded to storage and embedded in content; the /images API (pages/api/teams/[...]/images.ts) isn’t used, so annotation.images will stay empty. If downstream UI (e.g., AnnotationPanel’s image grid) depends on annotation.images, consider POSTing metadata there after upload, or confirm server will extract images from content.

I can wire a small helper to POST {filename,url,size,mimeType} to the images endpoint after upload if desired.


3-3: Remove unused import.

useEffect is imported but not used.

-import { useEffect, useState } from "react";
+import { useState } from "react";
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts (1)

1-11: Validate request body with zod and enforce safe constraints.

Replace ad-hoc checks with a schema: require https URL, image/* MIME, and reasonable size limits. This reduces attack surface and bad data.

 import { NextApiRequest, NextApiResponse } from "next";
 
 import { authOptions } from "@/pages/api/auth/[...nextauth]";
 import { getServerSession } from "next-auth/next";
 
 import { errorhandler } from "@/lib/errorHandler";
 import prisma from "@/lib/prisma";
 import { getTeamWithUsersAndDocument } from "@/lib/team/helper";
 import { CustomUser } from "@/lib/types";
 import { log } from "@/lib/utils";
+import { z } from "zod";
@@
-    const { filename, url, size, mimeType } = req.body;
-
-    if (!filename || !url || !mimeType) {
-      return res.status(400).json({
-        error: "Missing required fields: filename, url, mimeType",
-      });
-    }
-
-    // Validate file type
-    if (!mimeType.startsWith("image/")) {
-      return res.status(400).json({ error: "Only image files are allowed" });
-    }
+    const BodySchema = z.object({
+      filename: z.string().min(1),
+      url: z
+        .string()
+        .url()
+        .refine((u) => u.startsWith("https://"), "URL must be https"),
+      size: z.number().int().nonnegative().max(10 * 1024 * 1024).optional(), // <=10MB
+      mimeType: z.string().startsWith("image/"),
+    });
+    const { filename, url, size, mimeType } = BodySchema.parse(req.body);

Optional: restrict allowed URL host to your blob/CDN domain(s).

Also applies to: 59-71

components/ui/rich-text-editor.tsx (1)

39-46: Disable base64 images to prevent massive payloads.

allowBase64: true can let large inline images bloat your JSON/content and DB. Since you already upload to storage, disallow base64.

-      Image.configure({
+      Image.configure({
         inline: true,
-        allowBase64: true,
+        allowBase64: false,
         HTMLAttributes: {
           class: "rounded-lg max-w-full h-auto",
         },
       }),
components/view/nav.tsx (1)

311-317: Good feature gating; consider UX when no annotations exist.

The toggle is hidden unless both onToggleAnnotations and annotationsFeatureEnabled are set—nice. When hasAnnotations is undefined, the button is disabled via default false in the toggle; if annotations may load later, consider passing a tri-state (loading vs none) to avoid a disabled control flicker.

components/view/annotations/annotation-panel.tsx (3)

85-107: Fix combined marks rendering (bold+italic loses one).

The current loop overwrites text when multiple marks exist. Wrap progressively so multiple marks compose.

-                  {node.content?.map((textNode: any, textIndex: number) => {
-                    if (textNode.type === "text") {
-                      let text = textNode.text;
-                      // Apply formatting if marks exist
-                      if (textNode.marks) {
-                        textNode.marks.forEach((mark: any) => {
-                          if (mark.type === "bold") {
-                            text = (
-                              <strong key={textIndex} className="font-semibold">
-                                {text}
-                              </strong>
-                            );
-                          } else if (mark.type === "italic") {
-                            text = (
-                              <em key={textIndex} className="italic">
-                                {text}
-                              </em>
-                            );
-                          }
-                        });
-                      }
-                      return text;
+                  {node.content?.map((textNode: any, textIndex: number) => {
+                    if (textNode.type === "text") {
+                      let el: React.ReactNode = textNode.text;
+                      for (const mark of textNode.marks ?? []) {
+                        if (mark.type === "bold") {
+                          el = <strong className="font-semibold">{el}</strong>;
+                        } else if (mark.type === "italic") {
+                          el = <em className="italic">{el}</em>;
+                        }
+                      }
+                      return <span key={textIndex}>{el}</span>;

121-146: List rendering is brittle.

Accessing list item text via nested indices will drop marks and break on deeper structures. Prefer rendering child nodes recursively or using TipTap’s generateHTML with your extensions to produce safe HTML.

I can refactor renderContent to a small recursive renderer or switch to generateHTML with a sanitization step.


260-266: Privacy: consider no-referrer on external images.

Loading third‑party images leaks viewer referrers. Add referrerPolicy="no-referrer" to tags (both here and in paragraph images).

-                              <img
+                              <img
                                 src={image.url}
                                 alt={image.filename}
                                 className="h-auto max-h-32 w-full object-contain"
                                 loading="lazy"
+                                referrerPolicy="no-referrer"
                               />
pages/view/[linkId]/index.tsx (1)

197-201: annotationsEnabled computed for dataroom but not passed to DataroomView.

This makes the flag ineffective for dataroom links.

         <DataroomView
           link={link}
           userEmail={verifiedEmail ?? storedEmail ?? userEmail}
           verifiedEmail={verifiedEmail}
           userId={userId}
           isProtected={!!(emailProtected || linkPassword || enableAgreement)}
           brand={brand}
           disableEditEmail={!!disableEditEmail}
           useCustomAccessForm={useCustomAccessForm}
           logoOnAccessForm={logoOnAccessForm}
           token={storedToken}
           previewToken={previewToken}
           preview={!!preview}
           dataroomIndexEnabled={dataroomIndexEnabled}
+          annotationsEnabled={annotationsEnabled}
         />

Also applies to: 468-482

prisma/schema/annotation.prisma (2)

6-13: Consider removing denormalized teamId or enforce consistency.

DocumentAnnotation stores teamId even though Document already implies a team. This can drift. Either:

  • Drop teamId and join via document.teamId, or
  • Keep it for query ergonomics but add invariants in application code (write-time check that annotation.teamId === document.teamId) and consistently scope queries by both.

Want a small Prisma middleware to enforce this invariant on create/update?


17-19: Indexing is good; consider a composite index for common filters.

If your hot queries filter by { documentId, isVisible } or { teamId, documentId }, a composite index may help.

Also applies to: 32-33

components/view/viewer/pages-horizontal-viewer.tsx (2)

607-615: Ensure resizable layout reflows when toggling annotations.

defaultSize is only used on mount; toggling the panel may not resize as intended. Force remount on toggle so defaults reapply.

-        <ResizablePanelGroup direction="horizontal">
+        <ResizablePanelGroup
+          direction="horizontal"
+          key={`${Number(annotationsEnabled)}-${Number(!!hasAnnotations)}`}
+        >

Also applies to: 879-897


823-834: Add accessible labels to navigation buttons.

Screen readers currently get icon-only buttons.

-                  <button
+                  <button
+                    aria-label="Previous page"
                     onClick={!isMobile ? goToPreviousPage : undefined}
                     className={cn(
                       "rounded-full bg-gray-950/50 p-1 transition-opacity duration-200 hover:bg-gray-950/75",
                       "opacity-50 group-hover:opacity-100",
                     )}
                   >
...
-                  <button
+                  <button
+                    aria-label="Next page"
                     onClick={!isMobile ? goToNextPage : undefined}
                     className={cn(
                       "rounded-full bg-gray-950/50 p-1 transition-opacity duration-200 hover:bg-gray-950/75",
                       "opacity-50 group-hover:opacity-100",
                     )}
                   >

Also applies to: 846-857

components/documents/annotations/annotation-sheet.tsx (2)

59-60: Type the editing state to avoid any.

Use the shared Annotation type to improve safety.

-  const [editingAnnotation, setEditingAnnotation] = useState<any>(null);
+  import type { Annotation } from "@/lib/swr/use-annotations";
+  const [editingAnnotation, setEditingAnnotation] = useState<Annotation | null>(null);

82-112: Optional: optimistic visibility toggles.

For a snappier UX, optimistically flip isVisible and rollback on failure.

If you want, I can provide a mutate pattern with rollback using SWR’s optimistic updates.

pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (4)

13-23: Tighten validation: enforce integer pages and safer content shape.

  • Use .int().positive() for page numbers (disallow floats/0/negatives).
  • Deduplicate/sort pages on input to avoid duplicate storage.
  • Prefer z.unknown() or z.record(z.unknown()) over z.any() to keep JSON-safe typing.

Apply:

-const createAnnotationSchema = z.object({
-  title: z
-    .string()
-    .min(1, "Title is required")
-    .max(100, "Title must be less than 100 characters"),
-  content: z.record(z.any()), // Rich text content as JSON
-  pages: z
-    .array(z.number().min(1))
-    .min(1, "At least one page must be selected"),
-  isVisible: z.boolean().default(true),
-});
+const createAnnotationSchema = z.object({
+  title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters"),
+  // Rich text content as JSON (opaque to server)
+  content: z.record(z.unknown()),
+  pages: z
+    .array(z.number().int().positive())
+    .min(1, "At least one page must be selected")
+    .transform((arr) => Array.from(new Set(arr)).sort((a, b) => a - b)),
+  isVisible: z.boolean().default(true),
+});

69-74: Don’t log PII verbatim to Slack.

The log includes userId and teamId. Prefer hashing/truncation or omit unless strictly required.

Example:

- message: `Failed to get annotations for document: _${docId}_. \n\n ${error} \n\n*Metadata*: \`{teamId: ${teamId}, userId: ${userId}}\``,
+ message: `Failed to get annotations for document: _${docId}_.\n\n${String(error)}\n\n*Metadata*: \`{teamId: ${teamId.slice(-6)}, userId: ${userId.slice(-6)}}\``,

73-74: Return after handling error to avoid fall-through.

Ensures no further processing after error response.

-      errorhandler(error, res);
+      return errorhandler(error, res);

Also applies to: 127-128


25-25: Remove unused updateAnnotationSchema or wire PATCH/PUT.

Currently unused; keep dead code out or implement update route.

lib/swr/use-annotations.ts (4)

5-26: Narrow types for content and dates.

Prefer Record<string, unknown> for content. If API returns ISO strings, keep string; if Dates, align consistently.

-  content: any;
+  content: Record<string, unknown>;

28-36: Avoid requests with missing IDs by using a null SWR key.

Prevents hitting /annotations with “undefined” segments.

-export function useAnnotations(documentId: string, teamId: string) {
-  const { data, error, mutate } = useSWR<Annotation[]>(
-    `/api/teams/${teamId}/documents/${documentId}/annotations`,
+export function useAnnotations(documentId?: string, teamId?: string) {
+  const key =
+    teamId && documentId
+      ? `/api/teams/${teamId}/documents/${documentId}/annotations`
+      : null;
+  const { data, error, mutate } = useSWR<Annotation[]>(
+    key,
     fetcher,
     {
       revalidateOnFocus: false,
       revalidateOnReconnect: false,
     },
   );

53-56: Build viewer endpoint without viewId when it’s undefined.

Prevents ?viewId=undefined in the URL.

-  const endpoint = documentId
-    ? `/api/links/${linkId}/documents/${documentId}/annotations?viewId=${viewId}`
-    : `/api/links/${linkId}/annotations?viewId=${viewId}`;
+  const search = viewId ? `?viewId=${encodeURIComponent(viewId)}` : "";
+  const endpoint = documentId
+    ? `/api/links/${linkId}/documents/${documentId}/annotations${search}`
+    : `/api/links/${linkId}/annotations${search}`;

57-64: Also null-key viewer hook if linkId is missing.

Avoids accidental requests before nav data loads.

-  const { data, error, mutate } = useSWR<Omit<Annotation, "createdBy">[]>(
-    endpoint,
+  const key = linkId ? endpoint : null;
+  const { data, error, mutate } = useSWR<Omit<Annotation, "createdBy">[]>(
+    key,
     fetcher,
     {
       revalidateOnFocus: false,
       revalidateOnReconnect: false,
     },
   );
components/view/viewer/pages-vertical-viewer.tsx (3)

120-125: Guard annotation fetching behind the feature flag to avoid unnecessary requests.

Currently fetches even when annotations are disabled.

-  // Fetch annotations for this link
-  const { annotations } = useViewerAnnotations(linkId, documentId, viewId);
+  // Fetch annotations only when feature enabled
+  const shouldFetchAnnotations = !!navData.annotationsFeatureEnabled;
+  const { annotations } = useViewerAnnotations(
+    shouldFetchAnnotations ? linkId : undefined,
+    shouldFetchAnnotations ? documentId : undefined,
+    shouldFetchAnnotations ? viewId : undefined,
+  );

778-783: Make the resizable layout reinitialize when the right panel mounts/unmounts.

defaultSize applies only on initial mount. Without reinit, toggling annotations may not adjust the left panel’s size consistently.

-        <ResizablePanelGroup direction="horizontal">
+        <ResizablePanelGroup
+          key={`${annotationsEnabled}-${!!hasAnnotations}`}
+          direction="horizontal"
+        >
           {/* Document Content */}
-          <ResizablePanel
-            defaultSize={annotationsEnabled && hasAnnotations ? 75 : 100}
-          >
+          <ResizablePanel defaultSize={annotationsEnabled && hasAnnotations ? 75 : 100}>

Please confirm whether your ResizablePanelGroup resets state on child structure changes; if it already does, this can be skipped.


1052-1069: Render gate is solid; consider preloading annotations on toggle to reduce first-open latency.

Optional: call mutate() when the toggle flips on.

-  const handleToggleAnnotations = (enabled: boolean) => {
-    setAnnotationsEnabled(enabled);
-  };
+  const handleToggleAnnotations = (enabled: boolean) => {
+    setAnnotationsEnabled(enabled);
+    if (enabled && typeof (mutate as any) === "function") {
+      // @ts-ignore mutate from hook scope if needed
+      mutate?.();
+    }
+  };
prisma/migrations/20250915000000_add_annotations/migration.sql (2)

19-20: Enforce non-null, non-empty pages at DB level.

Aligns DB constraints with API validation; prevents null/empty arrays.

-    "pages" INTEGER[],
+    "pages" INTEGER[] NOT NULL,

And add a check constraint:

+-- Ensure at least one page
+ALTER TABLE "DocumentAnnotation"
+  ADD CONSTRAINT "DocumentAnnotation_pages_nonempty_chk"
+  CHECK (array_length("pages", 1) IS NOT NULL AND array_length("pages", 1) >= 1);

37-41: Add a covering index for viewer/admin queries.

Likely filters by documentId and isVisible, sorts by createdAt.

+-- Composite index for common access pattern
+CREATE INDEX "DocumentAnnotation_doc_visible_createdAt_idx"
+  ON "DocumentAnnotation"("documentId" ASC, "isVisible" ASC, "createdAt" DESC);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aefe99a and 4e88ee1.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • components/documents/annotations/annotation-form.tsx (1 hunks)
  • components/documents/annotations/annotation-sheet.tsx (1 hunks)
  • components/ui/form-hook.tsx (1 hunks)
  • components/ui/resizable.tsx (1 hunks)
  • components/ui/rich-text-editor.tsx (1 hunks)
  • components/view/annotations/annotation-panel.tsx (1 hunks)
  • components/view/annotations/annotation-toggle.tsx (1 hunks)
  • components/view/document-view.tsx (3 hunks)
  • components/view/nav.tsx (4 hunks)
  • components/view/view-data.tsx (3 hunks)
  • components/view/viewer/pages-horizontal-viewer.tsx (4 hunks)
  • components/view/viewer/pages-vertical-viewer.tsx (4 hunks)
  • components/view/viewer/pdf-default-viewer.tsx (1 hunks)
  • lib/featureFlags/index.ts (2 hunks)
  • lib/swr/use-annotations.ts (1 hunks)
  • package.json (3 hunks)
  • pages/api/links/[id]/annotations.ts (1 hunks)
  • pages/api/links/[id]/documents/[documentId]/annotations.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1 hunks)
  • pages/documents/[id]/index.tsx (3 hunks)
  • pages/view/[linkId]/index.tsx (7 hunks)
  • prisma/migrations/20250915000000_add_annotations/migration.sql (1 hunks)
  • prisma/schema/annotation.prisma (1 hunks)
  • prisma/schema/document.prisma (1 hunks)
  • prisma/schema/schema.prisma (1 hunks)
  • prisma/schema/team.prisma (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (18)
components/view/viewer/pdf-default-viewer.tsx (1)
lib/tracking/tracking-config.ts (1)
  • getTrackingOptions (17-35)
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts (5)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1)
  • handle (27-133)
pages/api/auth/[...nextauth].ts (1)
  • authOptions (35-204)
lib/types.ts (1)
  • CustomUser (17-17)
lib/team/helper.ts (1)
  • getTeamWithUsersAndDocument (30-95)
lib/utils.ts (1)
  • log (64-124)
components/view/annotations/annotation-toggle.tsx (2)
components/ui/tooltip.tsx (4)
  • TooltipProvider (160-160)
  • Tooltip (155-155)
  • TooltipTrigger (156-156)
  • TooltipContent (159-159)
components/ui/button.tsx (1)
  • Button (71-71)
pages/api/links/[id]/documents/[documentId]/annotations.ts (3)
pages/api/links/[id]/annotations.ts (1)
  • handle (10-90)
lib/featureFlags/index.ts (1)
  • getFeatureFlags (18-59)
lib/utils.ts (1)
  • log (64-124)
components/documents/annotations/annotation-sheet.tsx (2)
lib/swr/use-annotations.ts (1)
  • useAnnotations (28-44)
components/documents/annotations/annotation-form.tsx (1)
  • AnnotationForm (48-230)
components/view/annotations/annotation-panel.tsx (2)
lib/swr/use-annotations.ts (1)
  • useViewerAnnotations (46-72)
lib/utils/determine-text-color.ts (1)
  • determineTextColor (24-28)
pages/api/links/[id]/annotations.ts (3)
pages/api/links/[id]/documents/[documentId]/annotations.ts (1)
  • handle (8-118)
lib/featureFlags/index.ts (1)
  • getFeatureFlags (18-59)
lib/utils.ts (1)
  • log (64-124)
components/ui/resizable.tsx (1)
lib/utils.ts (1)
  • cn (18-20)
components/view/viewer/pages-horizontal-viewer.tsx (2)
lib/swr/use-annotations.ts (1)
  • useViewerAnnotations (46-72)
components/view/annotations/annotation-panel.tsx (1)
  • AnnotationPanel (35-280)
components/view/viewer/pages-vertical-viewer.tsx (3)
lib/swr/use-annotations.ts (1)
  • useViewerAnnotations (46-72)
components/ui/resizable.tsx (3)
  • ResizablePanelGroup (45-45)
  • ResizablePanel (45-45)
  • ResizableHandle (45-45)
components/view/annotations/annotation-panel.tsx (1)
  • AnnotationPanel (35-280)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (4)
pages/api/auth/[...nextauth].ts (1)
  • authOptions (35-204)
lib/types.ts (1)
  • CustomUser (17-17)
lib/team/helper.ts (1)
  • getTeamWithUsersAndDocument (30-95)
lib/utils.ts (1)
  • log (64-124)
components/view/nav.tsx (1)
components/view/annotations/annotation-toggle.tsx (1)
  • AnnotationToggle (19-56)
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts (6)
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts (1)
  • handle (27-131)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1)
  • handle (27-133)
pages/api/auth/[...nextauth].ts (1)
  • authOptions (35-204)
lib/types.ts (1)
  • CustomUser (17-17)
lib/team/helper.ts (1)
  • getTeamWithUsersAndDocument (30-95)
lib/utils.ts (1)
  • log (64-124)
lib/swr/use-annotations.ts (1)
lib/utils.ts (1)
  • fetcher (48-62)
pages/documents/[id]/index.tsx (2)
lib/utils.ts (1)
  • fetcher (48-62)
components/documents/annotations/annotation-sheet.tsx (1)
  • AnnotationSheet (50-340)
components/ui/form-hook.tsx (1)
lib/utils.ts (1)
  • cn (18-20)
pages/view/[linkId]/index.tsx (1)
lib/featureFlags/index.ts (1)
  • getFeatureFlags (18-59)
components/documents/annotations/annotation-form.tsx (2)
lib/utils.ts (1)
  • uploadImage (519-529)
components/ui/rich-text-editor.tsx (1)
  • RichTextEditor (29-246)
⏰ 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: Socket Security: Pull Request Alerts
🔇 Additional comments (15)
package.json (1)

34-34: Deps additions look good.

Versions align with React 18/Next 14 and match usage introduced in this PR.

Also applies to: 68-71, 126-126, 132-132

pages/api/links/[id]/documents/[documentId]/annotations.ts (1)

37-43: Feature-flag gate is correct.

Early-returning an empty array when disabled matches expected viewer behavior.

prisma/schema/document.prisma (2)

30-31: Relation addition LGTM.

annotations DocumentAnnotation[] is the right side of the one-to-many and matches the usage in queries.


1-39: Cascading deletes verified for annotations. DocumentAnnotation.document and AnnotationImage.annotation both use onDelete: Cascade in prisma/schema/annotation.prisma.

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

41-47: SWR gating looks good.

Key is null until team is known, avoiding unnecessary requests. No action needed.

prisma/schema/team.prisma (1)

25-25: Team.annotations backref — verified. DocumentAnnotation declares teamId and a Team relation with onDelete: Cascade; Team.annotations is the implicit back-relation and no other Team↔DocumentAnnotation relations were found.

components/view/view-data.tsx (1)

61-62: Nav wiring LGTM — annotationsFeatureEnabled propagated and used.
components/view/view-data.tsx sets annotationsFeatureEnabled (line 102); components/view/nav.tsx defines/destructures/uses it (lines 66, 109, 311); viewers gate panels on navData.annotationsFeatureEnabled (pages-vertical-viewer.tsx:1053, pages-horizontal-viewer.tsx:880).

components/view/document-view.tsx (1)

68-69: Prop plumbing LGTM — confirm all callers pass annotationsEnabled.

annotationsEnabled is correctly typed and forwarded in components/view/document-view.tsx (lines 68–69; also 90–91 and 304–305). Sandbox ripgrep returned no files, so I couldn't locate usages — verify all callers pass annotationsEnabled or confirm leaving it undefined is intentional.

pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts (1)

83-90: Return createdAt/updatedAt consistently or include include: {} if needed.

If clients expect timestamps or additional fields on create (similar to GET), consider aligning response shape via include/select.

pages/view/[linkId]/index.tsx (1)

119-122: LGTM on wiring the annotations feature flag into document links.

Flag retrieval and prop threading to DocumentView look consistent with getFeatureFlags.

Please confirm DocumentView propagates annotationsEnabled into its navData.annotationsFeatureEnabled so the viewer can render the toggle.

Also applies to: 163-164, 272-273

components/view/viewer/pages-horizontal-viewer.tsx (1)

580-590: Nice integration of viewer annotations + toggle.

Data fetch, toggle plumbed through Nav, and conditional right panel rendering look solid.

Please confirm Nav surfaces onToggleAnnotations and annotationsEnabled as expected and that the toggle is hidden when !navData.annotationsFeatureEnabled.

Also applies to: 593-601, 879-897

components/documents/annotations/annotation-sheet.tsx (1)

120-129: Sheet UX and CRUD flows look good.

Trigger, create dialog, list/empty-state, and SWR mutate usage are clean.

Also applies to: 168-185

prisma/migrations/20250915000000_add_annotations/migration.sql (1)

24-26: Confirm Prisma model uses @updatedAt.

No DB default exists; ensure application sets updatedAt.

Reuse the script provided in the API review to verify @updatedAt on updatedAt in the model.

components/ui/form-hook.tsx (1)

91-105: Label association: minor nit.

Using htmlFor={formItemId} is fine as FormControl assigns the same id. Keep as-is.

pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1)

95-112: Resolved — DocumentAnnotation includes @updatedat

Verified prisma/schema/annotation.prisma defines updatedAt DateTime @updatedAt (prisma/schema/annotation.prisma:15); Prisma will populate this on create, so the migration's NOT NULL column is satisfied.

Comment on lines +46 to +67
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();

const fieldState = getFieldState(fieldContext.name, formState);

if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}

const { id } = itemContext;

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
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

Fix useFormField provider checks and call order to avoid runtime errors.

getFieldState(fieldContext.name, ...) is called before verifying context presence; using the hook outside <FormField> can pass undefined to RHF. Also guard against missing <FormItem>.

Apply:

-const useFormField = () => {
-  const fieldContext = React.useContext(FormFieldContext);
-  const itemContext = React.useContext(FormItemContext);
-  const { getFieldState, formState } = useFormContext();
-
-  const fieldState = getFieldState(fieldContext.name, formState);
-
-  if (!fieldContext) {
-    throw new Error("useFormField should be used within <FormField>");
-  }
-
-  const { id } = itemContext;
-
-  return {
-    id,
-    name: fieldContext.name,
-    formItemId: `${id}-form-item`,
-    formDescriptionId: `${id}-form-item-description`,
-    formMessageId: `${id}-form-item-message`,
-    ...fieldState,
-  };
-};
+const useFormField = () => {
+  const fieldContext = React.useContext(FormFieldContext);
+  const itemContext = React.useContext(FormItemContext);
+  if (!fieldContext?.name) {
+    throw new Error("useFormField must be used within <FormField>");
+  }
+  if (!itemContext?.id) {
+    throw new Error("useFormField must be used within <FormItem>");
+  }
+  const { getFieldState, formState } = useFormContext();
+  const fieldState = getFieldState(fieldContext.name, formState);
+  const { id } = itemContext;
+  return {
+    id,
+    name: fieldContext.name,
+    formItemId: `${id}-form-item`,
+    formDescriptionId: `${id}-form-item-description`,
+    formMessageId: `${id}-form-item-message`,
+    ...fieldState,
+  };
+};
📝 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 useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
if (!fieldContext?.name) {
throw new Error("useFormField must be used within <FormField>");
}
if (!itemContext?.id) {
throw new Error("useFormField must be used within <FormItem>");
}
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};

Comment on lines +54 to +60
const currentPageAnnotations = useMemo(() => {
if (!annotations) return [];
return annotations.filter((annotation) =>
annotation.pages.includes(currentPage),
);
}, [annotations, currentPage]);

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

Honor annotation.isVisible (defense-in-depth).

Filter out annotations marked non-visible in addition to page filtering. Even if the API already strips them, UI should defensively hide.

-  const currentPageAnnotations = useMemo(() => {
+  const currentPageAnnotations = useMemo(() => {
     if (!annotations) return [];
-    return annotations.filter((annotation) =>
-      annotation.pages.includes(currentPage),
-    );
+    return annotations.filter(
+      (a) => a.isVisible && a.pages.includes(currentPage),
+    );
   }, [annotations, currentPage]);
📝 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 currentPageAnnotations = useMemo(() => {
if (!annotations) return [];
return annotations.filter((annotation) =>
annotation.pages.includes(currentPage),
);
}, [annotations, currentPage]);
const currentPageAnnotations = useMemo(() => {
if (!annotations) return [];
return annotations.filter(
(a) => a.isVisible && a.pages.includes(currentPage),
);
}, [annotations, currentPage]);
🤖 Prompt for AI Agents
components/view/annotations/annotation-panel.tsx around lines 54 to 60: the
currentPageAnnotations memo only filters by page but should also defensively
exclude annotations marked not visible; update the filter to only include
annotations where annotation.pages.includes(currentPage) AND
annotation.isVisible !== false (or Boolean(annotation.isVisible)) so any
annotation explicitly marked non-visible is filtered out in the UI.

Comment on lines +13 to +25
const updateAnnotationSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters")
.optional(),
content: z.record(z.any()).optional(), // Rich text content as JSON
pages: z
.array(z.number().min(1))
.min(1, "At least one page must be selected")
.optional(),
isVisible: z.boolean().optional(),
});
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

Accept null content and enforce integer pages in schema.

UpdateAnnotationSchema rejects content: null (UI sends null when editor empty) and allows non-integer page numbers. Fix to prevent 400s and sanitize pages.

Apply:

-const updateAnnotationSchema = z.object({
-  title: z
-    .string()
-    .min(1, "Title is required")
-    .max(100, "Title must be less than 100 characters")
-    .optional(),
-  content: z.record(z.any()).optional(), // Rich text content as JSON
-  pages: z
-    .array(z.number().min(1))
-    .min(1, "At least one page must be selected")
-    .optional(),
-  isVisible: z.boolean().optional(),
-});
+const updateAnnotationSchema = z.object({
+  title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters").optional(),
+  // Allow any JSON, including null
+  content: z.any().nullable().optional(),
+  // Only positive integers; at least one page if provided
+  pages: z.array(z.number().int().positive()).min(1, "At least one page must be selected").optional(),
+  isVisible: z.boolean().optional(),
+});

Optionally normalize pages on write:

-      const validatedData = updateAnnotationSchema.parse(req.body);
+      const validatedData = updateAnnotationSchema.parse(req.body);
+      if (validatedData.pages) {
+        validatedData.pages = Array.from(new Set(validatedData.pages)).sort((a, b) => a - b);
+      }
📝 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 updateAnnotationSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters")
.optional(),
content: z.record(z.any()).optional(), // Rich text content as JSON
pages: z
.array(z.number().min(1))
.min(1, "At least one page must be selected")
.optional(),
isVisible: z.boolean().optional(),
});
const updateAnnotationSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters").optional(),
// Allow any JSON, including null
content: z.any().nullable().optional(),
// Only positive integers; at least one page if provided
pages: z.array(z.number().int().positive()).min(1, "At least one page must be selected").optional(),
isVisible: z.boolean().optional(),
});
Suggested change
const updateAnnotationSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters")
.optional(),
content: z.record(z.any()).optional(), // Rich text content as JSON
pages: z
.array(z.number().min(1))
.min(1, "At least one page must be selected")
.optional(),
isVisible: z.boolean().optional(),
});
const validatedData = updateAnnotationSchema.parse(req.body);
if (validatedData.pages) {
validatedData.pages = Array.from(new Set(validatedData.pages)).sort((a, b) => a - b);
}

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: 2

♻️ Duplicate comments (1)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1)

41-65: Avoid team‑wide overfetch; query only this document’s annotations.

Current call loads all team documents (and their annotations), then picks one; wasteful and forces a // @ts-ignore. Fetch annotations directly after access check.

-      const { document } = await getTeamWithUsersAndDocument({
-        teamId,
-        userId,
-        docId,
-        options: {
-          include: {
-            annotations: {
-              include: {
-                images: true,
-                createdBy: {
-                  select: {
-                    id: true,
-                    name: true,
-                    email: true,
-                  },
-                },
-              },
-              orderBy: {
-                createdAt: "desc",
-              },
-            },
-          },
-        },
-      });
-
-      // @ts-ignore
-      return res.status(200).json(document?.annotations || []);
+      // Validate access; avoid heavy includes
+      await getTeamWithUsersAndDocument({ teamId, userId, docId });
+      const annotations = await prisma.documentAnnotation.findMany({
+        where: { documentId: docId, teamId },
+        include: {
+          images: true,
+          createdBy: { select: { id: true, name: true, email: true } },
+        },
+        orderBy: { createdAt: "desc" },
+      });
+      return res.status(200).json(annotations);

Also applies to: 67-68

🧹 Nitpick comments (9)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (3)

25-26: Remove unused updateAnnotationSchema here or extract shared schemas.

It’s not used in this route and risks drift with the per‑annotation route. Either delete it here or move both create/update schemas to a shared module (e.g., lib/validation/annotations.ts) and import in both API files.

-const updateAnnotationSchema = createAnnotationSchema.partial();

49-56: Minimize PII in responses unless required.

Do you need email for listing? If not, drop it to reduce PII exposure.

-          createdBy: {
-            select: {
-              id: true,
-              name: true,
-              email: true,
-            },
-          },
+          createdBy: { select: { id: true, name: true } },

31-76: Consider pagination for large annotation sets.

Add limit/cursor to prevent unbounded payloads and client render cost.

components/documents/annotations/annotation-form.tsx (6)

28-36: Align client schema: drop content from form schema and ensure integer pages.

content isn’t a controlled RHF field; keep it out of the form schema to avoid confusion. Also mark pages as integers.

-const formSchema = z.object({
+const formSchema = z.object({
   title: z
     .string()
     .min(1, "Title is required")
     .max(100, "Title must be less than 100 characters"),
-  content: z.any().optional(),
-  pages: z.array(z.number()).min(1, "At least one page must be selected"),
+  pages: z.array(z.number().int()).min(1, "At least one page must be selected"),
   isVisible: z.boolean(),
 });

60-69: Remove unused content default from form values.

It isn’t bound to any field and can drift from editorContent.

   const form = useForm<FormValues>({
     resolver: zodResolver(formSchema),
     defaultValues: {
       title: annotation?.title || "",
-      content: annotation?.content || null,
       pages: annotation?.pages || [],
       isVisible:
         annotation?.isVisible !== undefined ? annotation.isVisible : true,
     },
   });

108-112: Bubble up Zod field errors from the API for better UX.

Surface server‑side validation details when available.

-      if (!response.ok) {
-        const errorData = await response.json();
-        throw new Error(errorData.error || "Failed to save annotation");
-      }
+      if (!response.ok) {
+        const errorData = await response.json();
+        const detailMsg =
+          Array.isArray(errorData?.details) && errorData.details.length
+            ? errorData.details.map((d: any) => d.message).join("; ")
+            : undefined;
+        throw new Error(detailMsg || errorData.error || "Failed to save annotation");
+      }

133-147: Enforce 100‑char max client‑side.

-                <Input placeholder="Enter annotation title" {...field} />
+                <Input
+                  placeholder="Enter annotation title"
+                  maxLength={100}
+                  {...field}
+                />

40-46: Type annotation to the server DTO to avoid any.

Use a shared type (e.g., Annotation DTO or Prisma DocumentAnnotation pick) so refactors are safer.


73-86: Optional: persist image metadata via the images API.

If you intend to populate annotation.images, call POST /api/teams/:teamId/documents/:id/annotations/:annotationId/images after create/update by extracting image URLs from the editor JSON.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4e88ee1 and 1a305ee.

📒 Files selected for processing (3)
  • components/documents/annotations/annotation-form.tsx (1 hunks)
  • components/ui/rich-text-editor.tsx (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • components/ui/rich-text-editor.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (6)
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts (1)
  • handle (12-91)
pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts (1)
  • handle (27-131)
pages/api/auth/[...nextauth].ts (1)
  • authOptions (35-204)
lib/types.ts (1)
  • CustomUser (17-17)
lib/team/helper.ts (1)
  • getTeamWithUsersAndDocument (30-95)
lib/utils.ts (1)
  • log (64-124)
components/documents/annotations/annotation-form.tsx (2)
lib/utils.ts (1)
  • uploadImage (519-529)
components/ui/rich-text-editor.tsx (1)
  • RichTextEditor (29-246)
🔇 Additional comments (1)
components/documents/annotations/annotation-form.tsx (1)

165-170: Guard against indeterminate state from Checkbox.

Some Checkbox libs can pass "indeterminate". Be explicit.

-                      onCheckedChange={(checked) => {
-                        if (checked) {
+                      onCheckedChange={(checked) => {
+                        if (checked === true) {
                           field.onChange([...field.value, page]);
                         } else {
                           field.onChange(field.value.filter((p) => p !== page));
                         }
                       }}

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: 2

♻️ Duplicate comments (3)
components/documents/annotations/annotation-form.tsx (1)

55-58: Good default for TipTap content; add JSONContent typing.

Initialize with a valid doc is correct. Type it to avoid any drift.

-  const [editorContent, setEditorContent] = useState(
-    annotation?.content || { type: "doc", content: [] },
-  );
+  const [editorContent, setEditorContent] = useState<JSONContent>(
+    (annotation?.content as JSONContent) ?? { type: "doc", content: [] },
+  );

Add the type import:

+import type { JSONContent } from "@tiptap/core";
pages/api/links/[id]/annotations.ts (1)

22-42: Use findFirst or a compound-unique selector; current findUnique shape is invalid

findUnique requires a unique selector. { id, linkId } isn’t valid unless you use the compound alias (e.g., id_linkId). Use findFirst or the compound key.

Apply this diff:

-    const view = await prisma.view.findUnique({
-      where: { id: viewId, linkId: linkId },
+    const view = await prisma.view.findFirst({
+      where: { id: viewId, linkId },
       include: {
-        link: true,
+        link: true,
         document: {
           include: {
             annotations: {
               where: {
                 isVisible: true, // Only return visible annotations for viewers
               },
               include: {
                 images: true,
               },
               orderBy: {
                 createdAt: "desc",
               },
             },
           },
         },
       },
     });

If you have a compound unique, prefer:

prisma.view.findUnique({ where: { id_linkId: { id: viewId, linkId } }, include: { link: true, document: { /* … */ } } })
pages/api/links/[id]/documents/[documentId]/annotations.ts (1)

28-31: Use findFirst or compound-unique alias; current findUnique selector is invalid

Same issue: { id, linkId } isn’t a valid unique selector unless using the compound alias.

-    const view = await prisma.view.findUnique({
-      where: { id: viewId, linkId: linkId },
+    const view = await prisma.view.findFirst({
+      where: { id: viewId, linkId },
       select: {
         id: true,
         viewedAt: true,
         link: {
           select: {
             id: true,
             linkType: true,
             teamId: true,
             documentId: true,
             dataroomId: true,
           },
         },
       },
     });

Or, if defined, use the compound key (e.g., where: { id_linkId: { id: viewId, linkId } }).

🧹 Nitpick comments (13)
components/documents/annotations/annotation-form.tsx (6)

28-36: Drop content from the form schema (it’s sourced from editor state anyway).

Avoid schema/default duplication and potential drift; content is not bound to RHF fields.

 const formSchema = z.object({
   title: z
     .string()
     .min(1, "Title is required")
     .max(100, "Title must be less than 100 characters"),
-  content: z.any().optional(),
-  pages: z.array(z.number()).min(1, "At least one page must be selected"),
+  pages: z
+    .array(z.number().int().gte(1))
+    .min(1, "At least one page must be selected"),
   isVisible: z.boolean(),
 });

62-68: Remove content from defaultValues to stay consistent with schema and submit payload.

   defaultValues: {
     title: annotation?.title || "",
-    content: annotation?.content || null,
     pages: annotation?.pages || [],
     isVisible:
       annotation?.isVisible !== undefined ? annotation.isVisible : true,
   },

69-71: Reset form when annotation prop changes (edit/create switch).

Prevents stale fields when editing different annotations.

   });
 
+  useEffect(() => {
+    form.reset({
+      title: annotation?.title || "",
+      pages: annotation?.pages || [],
+      isVisible: annotation?.isVisible ?? true,
+    });
+    setEditorContent(
+      (annotation?.content as JSONContent) ?? { type: "doc", content: [] },
+    );
+  }, [annotation, form]);

If AnnotationSheet reuses this component across items, please verify this behaves as expected.


108-111: Harden error handling for non‑JSON error responses.

Avoids .json() throwing on HTML/plain-text errors.

-      if (!response.ok) {
-        const errorData = await response.json();
-        throw new Error(errorData.error || "Failed to save annotation");
-      }
+      if (!response.ok) {
+        let message = "Failed to save annotation";
+        try {
+          const ct = response.headers.get("content-type") || "";
+          if (ct.includes("application/json")) {
+            const data = await response.json();
+            message = data.error || data.message || message;
+          } else {
+            const text = await response.text();
+            if (text) message = text;
+          }
+        } catch {}
+        throw new Error(message);
+      }

160-171: Coerce checkbox state strictly to boolean to avoid “indeterminate” truthiness.

Prevents accidental adds if the component ever emits "indeterminate".

-                      onCheckedChange={(checked) => {
-                        if (checked) {
+                      onCheckedChange={(checked) => {
+                        if (checked === true) {
                           field.onChange([...field.value, page]);
                         } else {
                           field.onChange(field.value.filter((p) => p !== page));
                         }
                       }}

223-226: Optional: rely on RHF isSubmitting instead of manual isLoading.

Simplifies state and prevents double toggles.

Example:

-  const [isLoading, setIsLoading] = useState(false);
+  // remove local loading state; use form.formState.isSubmitting

-  setIsLoading(true);
   // ... submit logic ...
-  setIsLoading(false);

-  <Button type="submit" disabled={isLoading}>
+  <Button type="submit" disabled={form.formState.isSubmitting}>
pages/api/links/[id]/annotations.ts (5)

48-51: Align TTL violation status with sibling endpoint

This returns 404; the document-level endpoint returns 403. Use 403 for policy denials.

-    if (view.viewedAt < new Date(Date.now() - 1000 * 60 * 60 * 23)) {
-      // if view is older than 23 hours, we should not allow the annotations to be accessed
-      return res.status(404).json({ error: "Annotation not found" });
-    }
+    const cutoff = new Date(Date.now() - 23 * 60 * 60 * 1000);
+    if (view.viewedAt < cutoff) {
+      // if view is older than 23 hours, we should not allow the annotations to be accessed
+      return res.status(403).json({ error: "Access denied" });
+    }

24-26: Avoid over-fetching: select only needed link fields

You only use linkType and teamId. Select them to reduce payload.

-      include: {
-        link: true,
+      include: {
+        link: {
+          select: { linkType: true, teamId: true },
+        },
         document: {

Also applies to: 41-41


44-46: Clarify 404 reason

Return “View not found” for missing view to distinguish from “no annotations.”

-      return res.status(404).json({ error: "Annotation not found" });
+      return res.status(404).json({ error: "View not found" });

19-20: Optional: validate required query params early

Guard missing viewId/id with a 400 before DB calls.

   const { id: linkId, viewId } = req.query as { id: string; viewId: string };
+  if (!linkId || !viewId) {
+    return res.status(400).json({ error: "Missing linkId or viewId" });
+  }

21-42: Add no-store for viewer responses

These are TTL-gated and should not be cached by intermediaries.

   try {
+    res.setHeader("Cache-Control", "private, no-store");

Also applies to: 53-60, 82-83

pages/api/links/[id]/documents/[documentId]/annotations.ts (2)

64-69: Avoid non-null assertion on documentId

Add a guard to prevent runtime errors if data is inconsistent.

-      document = await prisma.document.findUnique({
-        where: { id: view.link.documentId! },
+      if (!view.link.documentId) {
+        return res.status(404).json({ error: "Document not found" });
+      }
+      document = await prisma.document.findUnique({
+        where: { id: view.link.documentId },

127-128: Set no-store caching for viewer payloads

Prevents stale/TTL-expired data from caching layers.

-    return res.status(200).json(sanitizedAnnotations);
+    res.setHeader("Cache-Control", "private, no-store");
+    return res.status(200).json(sanitizedAnnotations);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a305ee and 9c21b6e.

📒 Files selected for processing (5)
  • components/documents/annotations/annotation-form.tsx (1 hunks)
  • pages/api/links/[id]/annotations.ts (1 hunks)
  • pages/api/links/[id]/documents/[documentId]/annotations.ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts (1 hunks)
  • pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • pages/api/teams/[teamId]/documents/[id]/annotations/index.ts
  • pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts
🧰 Additional context used
🧬 Code graph analysis (3)
pages/api/links/[id]/documents/[documentId]/annotations.ts (3)
pages/api/links/[id]/annotations.ts (1)
  • handle (10-90)
lib/featureFlags/index.ts (1)
  • getFeatureFlags (18-59)
lib/utils.ts (1)
  • log (64-124)
components/documents/annotations/annotation-form.tsx (2)
lib/utils.ts (1)
  • uploadImage (519-529)
components/ui/rich-text-editor.tsx (1)
  • RichTextEditor (29-246)
pages/api/links/[id]/annotations.ts (3)
pages/api/links/[id]/documents/[documentId]/annotations.ts (1)
  • handle (8-135)
lib/featureFlags/index.ts (1)
  • getFeatureFlags (18-59)
lib/utils.ts (1)
  • log (64-124)
⏰ 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: Socket Security: Pull Request Alerts
🔇 Additional comments (1)
pages/api/links/[id]/documents/[documentId]/annotations.ts (1)

49-52: LGTM: TTL gating present and correct

Blocks views older than 23h with 403, matching policy. Keep this as the canonical behavior and align the sibling route.

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: 1

🧹 Nitpick comments (7)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (7)

14-24: Tighten validation: trim title and disallow unknown keys.

Prevents whitespace-only titles and mass-assignment via unexpected fields.

-const createAnnotationSchema = z.object({
-  title: z
-    .string()
-    .min(1, "Title is required")
-    .max(100, "Title must be less than 100 characters"),
+const createAnnotationSchema = z
+  .object({
+    title: z
+      .string()
+      .trim()
+      .min(1, "Title is required")
+      .max(100, "Title must be less than 100 characters"),
   content: z.record(z.any()).nullable().optional(), // Rich text content as JSON - allow null/omitted
   pages: z
     .array(z.number().int().min(1))
     .min(1, "At least one page must be selected"),
   isVisible: z.boolean().default(true),
-});
+})
+  .strict();

26-26: Unused schema.

updateAnnotationSchema is defined but unused. Remove it or wire it up in the PUT/PATCH route to avoid drift.

-const updateAnnotationSchema = createAnnotationSchema.partial();
+// TODO: add PUT/PATCH route and use this, or remove for now
+// const updateAnnotationSchema = createAnnotationSchema.partial();

45-55: Minimize PII in responses: avoid leaking emails unless strictly needed.

Returning createdBy.email to all team members may be unnecessary. Prefer id+name; add email only if the UI requires it.

-          createdBy: { select: { id: true, name: true, email: true } },
+          createdBy: { select: { id: true, name: true } },

And for POST include:

-          createdBy: {
-            select: {
-              id: true,
-              name: true,
-              email: true,
-            },
-          },
+          createdBy: { select: { id: true, name: true } },

Also applies to: 90-99


72-75: Normalize pages: de-duplicate and sort before persisting.

Prevents redundant rows and stabilizes ordering.

-      const validatedData = createAnnotationSchema.parse(req.body);
+      const validatedData = createAnnotationSchema.parse(req.body);
+      const normalizedPages = Array.from(new Set(validatedData.pages)).sort(
+        (a, b) => a - b,
+      );
-          ...validatedData,
+          ...validatedData,
+          pages: normalizedPages,

Also applies to: 82-90


45-52: Consider pagination for large annotation sets.

Add take/cursor (or limit/offset) query params to avoid unbounded responses for long-lived docs.


32-38: Reduce repetition: factor session retrieval.

Minor DRY: extract a small requireSession(req,res) helper returning { userId } (403/401 inside).

Also applies to: 62-68


56-61: Improve error log fidelity.

Log error.stack when available to aid triage; avoid embedding rich objects into Slack text.

-        message: `Failed to get annotations for document: _${docId}_. \n\n ${error} \n\n*Metadata*: \`{teamId: ${teamId}, userId: ${userId}}\``,
+        message: `Failed to get annotations for document: _${docId}_.\n\n${
+          error instanceof Error ? error.stack : String(error)
+        }\n\n*Metadata*: \`{teamId: ${teamId}, userId: ${userId}}\``,

Apply the same pattern in the POST path.

Also applies to: 111-115

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68d714d and 290db03.

📒 Files selected for processing (1)
  • pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (4)
pages/api/auth/[...nextauth].ts (1)
  • authOptions (35-204)
lib/types.ts (1)
  • CustomUser (17-17)
lib/team/helper.ts (1)
  • getTeamWithUsersAndDocument (30-95)
lib/utils.ts (1)
  • log (64-124)
🔇 Additional comments (1)
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts (1)

85-85: Good handling of JSON nulls.

Converting undefined/null to Prisma.JsonNull is correct for JSON columns and prevents DbNull mix-ups.

If the Prisma model allows both JSON null and DB null, ensure the column is JSON and not nullable at the DB level (or consistently use JsonNull).

Comment on lines +39 to +45
const { teamId, id: docId } = req.query as { teamId: string; id: string };
const userId = (session.user as CustomUser).id;

try {
// Validate access; avoid heavy includes
await getTeamWithUsersAndDocument({ teamId, userId, docId });
const annotations = await prisma.documentAnnotation.findMany({
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Critical: getTeamWithUsersAndDocument may throw when options are omitted (object spread of undefined).

lib/team/helper.ts spreads options into { ...options }. If options is undefined (as in both calls here), this can throw at runtime and 500 the route. Fix at the caller or make the helper resilient.

Quick caller-side fix (safe and local to this PR):

-      await getTeamWithUsersAndDocument({ teamId, userId, docId });
+      await getTeamWithUsersAndDocument({
+        teamId,
+        userId,
+        docId,
+        // Minimal selection to validate existence without heavy includes
+        options: { select: { id: true, links: true } },
+      });

Apply the same change to the POST path:

-      await getTeamWithUsersAndDocument({
-        teamId,
-        userId,
-        docId,
-      });
+      await getTeamWithUsersAndDocument({
+        teamId,
+        userId,
+        docId,
+        options: { select: { id: true, links: true } },
+      });

Repo-wide helper-side hardening (preferred, prevents future footguns):

--- a/lib/team/helper.ts
+++ b/lib/team/helper.ts
@@
   const team = await prisma.team.findUnique({
     where: { id: teamId },
     include: {
       users: { select: { userId: true } },
-      documents: {
-        ...options,
-      },
+      documents: options
+        ? options
+        : { select: { id: true, links: true, ownerId: true } },
     },
   });

Verification script:

Also applies to: 76-81


🏁 Script executed:

#!/bin/bash
# Confirm whether helper currently spreads possibly-undefined options
rg -n -C2 'getTeamWithUsersAndDocument|documents:\s*{\s*\.\.\.options' lib/team/helper.ts

# Find all call sites omitting options (to assess blast radius)
rg -nP 'getTeamWithUsersAndDocument\(\{\s*teamId:\s*|userId:\s*|docId:' -A4 | rg -n 'options:' -B4 -C0

Length of output: 266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "PWD: $(pwd)"
echo
echo "---- show lib/team/helper.ts (if present) ----"
if [ -f "lib/team/helper.ts" ]; then
  sed -n '1,400p' lib/team/helper.ts || true
else
  echo "lib/team/helper.ts not found"
fi

echo
echo "---- literal '...options' occurrences ----"
rg -n -F '...options' || true

echo
echo "---- literal 'getTeamWithUsersAndDocument(' occurrences ----"
rg -n -F 'getTeamWithUsersAndDocument(' -S || true

echo
echo "---- getTeamWithUsersAndDocument matches with context ----"
rg -n -F -C5 'getTeamWithUsersAndDocument(' || true

echo
echo "---- show target annotations API file (if present) ----"
if [ -f 'pages/api/teams/[teamId]/documents/[id]/annotations/index.ts' ]; then
  sed -n '1,240p' 'pages/api/teams/[teamId]/documents/[id]/annotations/index.ts' || true
else
  echo "file not found at pages/api/teams/[teamId]/documents/[id]/annotations/index.ts"
  rg -n --hidden --no-ignore 'annotations/index.ts' || true
fi

Length of output: 21131


Critical: guard against spreading undefined options in getTeamWithUsersAndDocument

Verified: lib/team/helper.ts uses documents: { ...options } (and similar patterns) and multiple call sites omit options (e.g. pages/api/teams/[teamId]/documents/[id]/annotations/index.ts GET/POST). Spreading a nullish value can cause a runtime TypeError — fix at callers or harden the helper. Prefer hardening the helper to prevent future footguns.

Quick caller-side fix (safe and local to this PR):

-      await getTeamWithUsersAndDocument({ teamId, userId, docId });
+      await getTeamWithUsersAndDocument({
+        teamId,
+        userId,
+        docId,
+        // Minimal selection to validate existence without heavy includes
+        options: { select: { id: true, links: true } },
+      });

Apply the same change to the POST path:

-      await getTeamWithUsersAndDocument({
-        teamId,
-        userId,
-        docId,
-      });
+      await getTeamWithUsersAndDocument({
+        teamId,
+        userId,
+        docId,
+        options: { select: { id: true, links: true } },
+      });

Repo-wide helper-side hardening (preferred):

--- a/lib/team/helper.ts
+++ b/lib/team/helper.ts
@@
   const team = await prisma.team.findUnique({
     where: { id: teamId },
     include: {
       users: { select: { userId: true } },
-      documents: {
-        ...options,
-      },
+      documents: options
+        ? options
+        : { select: { id: true, links: true, ownerId: 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
const { teamId, id: docId } = req.query as { teamId: string; id: string };
const userId = (session.user as CustomUser).id;
try {
// Validate access; avoid heavy includes
await getTeamWithUsersAndDocument({ teamId, userId, docId });
const annotations = await prisma.documentAnnotation.findMany({
const { teamId, id: docId } = req.query as { teamId: string; id: string };
const userId = (session.user as CustomUser).id;
try {
// Validate access; avoid heavy includes
await getTeamWithUsersAndDocument({
teamId,
userId,
docId,
// Minimal selection to validate existence without heavy includes
options: { select: { id: true, links: true } },
});
const annotations = await prisma.documentAnnotation.findMany({
🤖 Prompt for AI Agents
pages/api/teams/[teamId]/documents/[id]/annotations/index.ts lines 39-45: the
call to getTeamWithUsersAndDocument may cause a runtime TypeError because the
helper spreads a possibly undefined/null options object; fix by hardening the
helper to default options to an empty object and only spread when non-null (e.g.
set the function param default to {} and use documents: { ...(options || {}) }),
and as a quick local fix for this file ensure the call includes an explicit
empty options object (pass options: {}) so nothing undefined gets spread.

@mfts mfts merged commit 458247c into main Sep 15, 2025
9 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Sep 15, 2025
@mfts mfts deleted the feat/annotations branch September 16, 2025 14:39
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants