Skip to content

Comments

Visitor groups access#2073

Open
mfts wants to merge 11 commits intomainfrom
cursor/visitor-groups-access-1b01
Open

Visitor groups access#2073
mfts wants to merge 11 commits intomainfrom
cursor/visitor-groups-access-1b01

Conversation

@mfts
Copy link
Owner

@mfts mfts commented Feb 16, 2026

Add visitor groups for managing document and data room permissions and merge fixes for stale allow/deny list state in link settings.

The core problem addressed is the tedious and error-prone manual addition of individual emails/domains to multiple document/dataroom links. Visitor groups centralize viewer management, automatically updating access across all linked documents when a group is modified. The merged fixes from PR #2069 resolve issues where the allow/deny list sections in the link sheet could display stale data or behave inconsistently due to useEffect synchronization problems and lack of component remounting.


Slack Thread

Open in Cursor Open in Web

Summary by CodeRabbit

  • New Features

    • Visitor Groups: create/edit/delete groups of emails/domains, manage members, and assign groups to links.
    • UI: Visitor Groups management UI and modal; Visitors page adds tabs for Visitors and Visitor Groups.
    • Backend: APIs and data model support for visitor groups; links now carry group associations.
  • Behavior Changes

    • Access control now considers Visitor Group membership together with per-link allow-lists.
  • Documentation

    • Added numerous Postgres and frontend design reference docs.

cursoragent and others added 5 commits February 12, 2026 07:13
Add team-level visitor groups that allow users to define named groups
of emails/domains once, then apply them to document and data room links.

New models:
- VisitorGroup: team-scoped named group with emails/domains list
- LinkVisitorGroup: many-to-many join table between Link and VisitorGroup

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- GET/POST /api/teams/:teamId/visitor-groups - list & create
- GET/PUT/DELETE /api/teams/:teamId/visitor-groups/:groupId - CRUD
- Deletion protection: prevents deletion if group is used by active links
- SWR hook for consuming visitor groups data in UI

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
- Add VisitorGroupsSection component with create/edit/delete UI
- Add VisitorGroupModal for creating and editing groups
- Add 'Visitor Groups' tab to /visitors page
- Update AllowListSection with multi-group selector (popover with checkboxes)
- Add visitorGroupIds to DEFAULT_LINK_TYPE and link sheet data flow
- Update link create/update APIs to handle visitor group associations
- Update document and dataroom link fetch APIs to include visitorGroups
- Update LinkWithViews type to include visitorGroups
- Update views and views-dataroom routes to merge group emails with allow list
- Visitor group emails are additive: groups + individual emails combined

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Ensure dataroom group links endpoint also returns visitor group
associations for proper editing in the link sheet.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
Incorporates fixes from PR #2069 (cursor/link-allow-list-email-update):
- Replace useEffect-based sync with direct onChange handler updates for
  allow-list and deny-list, preventing stale data on quick saves
- useEffect now only handles disabling when emailProtected is turned off
- Add key props to AllowListSection and DenyListSection in link-options
  to force remount when switching between links (prevents stale state)
- Visitor group clearing is also handled in the new useEffect pattern

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
@cursor
Copy link

cursor bot commented Feb 16, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@vercel
Copy link

vercel bot commented Feb 16, 2026

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

Project Deployment Actions Updated (UTC)
papermark Ready Ready Preview, Comment Feb 21, 2026 2:23am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 16, 2026

Walkthrough

Adds visitor groups across DB, API, and UI: new Prisma models and migration, team-scoped visitor-group CRUD APIs, SWR hook and types, visitor-group UI and modal, link create/update flow and link edit UI extended with visitorGroupIds, and view authorization updated to combine link.allowList with visitor-group emails.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
prisma/schema/link.prisma, prisma/schema/team.prisma, prisma/migrations/20260212000000_add_visitor_groups/migration.sql
Introduces VisitorGroup and LinkVisitorGroup models, adds visitorGroups relation to Link and Team, and provides migration SQL with indexes and FKs.
View Authorization
app/api/views/route.ts, app/api/views-dataroom/route.ts
Includes visitorGroups in link selection, derives visitor-group emails, builds a combinedAllowList (link.allowList + visitor group emails), and evaluates access against that combined list.
Link CRUD APIs
pages/api/links/index.ts, pages/api/links/[id]/index.ts
Accepts/returns visitorGroupIds; create/update handlers create or replace join records (LinkVisitorGroup) and include visitorGroups in responses.
Document & Dataroom Link APIs
pages/api/teams/[teamId]/datarooms/[id]/links.ts, pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/links.ts, pages/api/teams/[teamId]/documents/[id]/links.ts
Augments link queries to include associated visitorGroups (selecting visitorGroupId) in returned link objects.
Visitor-Groups API
pages/api/teams/[teamId]/visitor-groups/index.ts, pages/api/teams/[teamId]/visitor-groups/[groupId]/index.ts
New team-scoped endpoints: list/create (GET/POST) and read/update/delete (GET/PUT/DELETE) with validation, link-usage checks, counts, and error handling.
Frontend: Link Model & Editor
components/links/link-sheet/index.tsx, components/links/link-sheet/allow-list-section.tsx, components/links/link-sheet/deny-list-section.tsx, components/links/link-sheet/link-options.tsx
Adds visitorGroupIds to default link shape; extends Allow List UI to select visitor groups (badges, popover, sync logic); refactors deny-list sync to immediate conditional updates; adds keys for stable mounts.
Frontend: Links Table
components/links/links-table.tsx
When opening edit, maps link.visitorGroups -> visitorGroupIds: string[] in the selected link payload.
Visitor Groups UI
components/visitors/visitor-group-modal.tsx, components/visitors/visitor-groups-section.tsx, pages/visitors/index.tsx
Adds VisitorGroupModal (create/edit), VisitorGroupsSection with cards/list, delete/edit flows, and a "Visitor Groups" tab on the Visitors page.
Data Fetching & Types
lib/swr/use-visitor-groups.ts, lib/types.ts
Adds useVisitorGroups SWR hook and VisitorGroupWithCount type; extends LinkWithViews with optional visitorGroups field.
Misc / Tooling & Docs
package.json, .agents/... (skill docs), LICENSE.txt, .cursor/...
Dependency version bumps in package.json and many new documentation/skill markdown files; license and skill pointers added (no runtime logic).

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 clearly summarizes the main feature added in this PR—visitor groups access—which is the primary focus of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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

Caution

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

⚠️ Outside diff range comments (1)
app/api/views/route.ts (1)

496-496: ⚠️ Potential issue | 🟡 Minor

Remove debug console.log.

This looks like a leftover debug statement that shouldn't ship to production.

-      console.log("previewSession", previewSession);
🤖 Fix all issues with AI agents
In `@components/visitors/visitor-group-modal.tsx`:
- Around line 62-94: The finally block currently calls setOpen(false) which
closes the modal even when the API returns an error; change control flow so that
setOpen(false) and the
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`) call only run
on successful responses (after response.ok and toast.success), while keeping
setLoading(false) in finally to always clear loading; ensure the catch block
shows the error toast but does NOT call setOpen(false) or mutate so user input
is preserved when saving fails.

In `@pages/api/links/`[id]/index.ts:
- Around line 426-443: The update handler currently deletes and recreates
link-visitor group associations without verifying the provided visitorGroupIds
belong to the same team as the link; before calling
tx.linkVisitorGroup.deleteMany/createMany, fetch the existing link (to get its
teamId) and query tx.visitorGroup.findMany({ where: { id: { in:
linkData.visitorGroupIds }, teamId: link.teamId } }) and ensure the returned
count matches linkData.visitorGroupIds.length (or reject any ids not matching) —
if there are mismatches throw a validation error and abort the transaction; only
then proceed with tx.linkVisitorGroup.deleteMany and
tx.linkVisitorGroup.createMany.
- Around line 424-444: The response returns stale visitorGroups because
tx.link.update captures the old related data into the link variable before you
delete/create associations with tx.linkVisitorGroup.deleteMany and createMany;
fix by ensuring the returned link includes the post-update associations—either
move the visitor-group deletion/creation logic to run before calling
tx.link.update (so its include picks up the new associations) or, after
createMany, re-query the link's visitorGroups (e.g., refetch via
tx.link.findUnique with include: { visitorGroups: true }) and use that fresh
result for the response instead of the original link variable.

In `@pages/api/links/index.ts`:
- Around line 240-251: Validate that each ID in linkData.visitorGroupIds belongs
to the requesting user's team before passing them into the Prisma create/update
payload (the visitorGroups: { createMany: { data: ... } } block). Specifically,
in both the POST and PATCH handlers where linkData.visitorGroupIds is used,
fetch the visitor groups by the provided IDs (e.g., via a DB call filtering on
id IN visitorGroupIds and teamId === request.teamId) and ensure the returned
count matches the input count; if not, throw a 400/403 error. Only after this
check, map the validated visitorGroupIds into the visitorGroups createMany data;
keep the same variable names (linkData, visitorGroupIds, visitorGroups) so the
rest of the handler remains unchanged.
🧹 Nitpick comments (8)
pages/api/teams/[teamId]/visitor-groups/[groupId]/index.ts (1)

77-105: Emails are stored without validation or sanitization.

The emails array from req.body is persisted directly without verifying that entries are valid email addresses or domain patterns. Malformed entries could silently break downstream access-control checks in the views routes (where isEmailMatched is used against these stored values).

Consider reusing the sanitizeList utility (from lib/utils.ts) or at minimum validating email/domain format server-side before storing.

Proposed fix
+  import { sanitizeList } from "@/lib/utils";
+
   // PUT /api/teams/:teamId/visitor-groups/:groupId
   const { name, emails } = req.body as { name: string; emails: string[] };

   if (!name || name.trim() === "") {
     return res.status(400).json({ error: "Group name is required." });
   }

+  const sanitizedEmails = emails
+    ? sanitizeList(emails.join("\n"))
+    : [];
+
   try {
     const visitorGroup = await prisma.visitorGroup.update({
       where: {
         id: groupId,
         teamId,
       },
       data: {
         name: name.trim(),
-        emails: emails || [],
+        emails: sanitizedEmails,
       },
#!/bin/bash
# Check if the same lack of validation exists in the POST handler
rg -n 'emails' pages/api/teams/*/visitor-groups/index.ts
fd 'index.ts' --path 'visitor-groups' --exec cat {}
pages/api/teams/[teamId]/visitor-groups/index.ts (2)

14-51: Consider extracting auth/team-access check before method routing.

The session retrieval and team access verification are duplicated between GET and POST branches. The sibling [groupId]/index.ts route already extracts this before method dispatch. Aligning here would reduce duplication.

Sketch
 export default async function handle(req, res) {
+  const session = await getServerSession(req, res, authOptions);
+  if (!session) return res.status(401).end("Unauthorized");
+  const { teamId } = req.query as { teamId: string };
+  const userId = (session.user as CustomUser).id;
+  const teamAccess = await prisma.userTeam.findUnique({
+    where: { userId_teamId: { userId, teamId } },
+  });
+  if (!teamAccess) return res.status(401).end("Unauthorized");
+
   if (req.method === "GET") {
-    const session = ...
-    // ...team access check...
     // ...rest of GET logic
   } else if (req.method === "POST") {
-    const session = ...
-    // ...team access check...
     // ...rest of POST logic
   }
 }

81-92: Same email validation gap as in the [groupId] PUT handler.

The emails array from req.body is stored directly without validation. Apply the same sanitization recommended for the [groupId] route to maintain consistency.

prisma/schema/link.prisma (1)

168-182: Consider adding a unique constraint on (teamId, name) to prevent duplicate group names within a team.

Without this, users could create multiple visitor groups with the same name under the same team, leading to confusion in the UI (e.g., the multi-group selector in AllowListSection).

Suggested change
   @@index([teamId])
+  @@unique([teamId, name])
 }
components/visitors/visitor-group-modal.tsx (1)

39-47: useEffect with existingGroup object reference may cause unnecessary re-runs.

If the parent re-renders and creates a new existingGroup object reference (even with the same data), this effect will re-fire and reset the form mid-edit. Consider comparing by existingGroup?.id and open instead, or memoizing the prop in the parent.

Minimal fix
   useEffect(() => {
     if (existingGroup) {
       setGroupName(existingGroup.name);
       setEmailsInput(existingGroup.emails.join("\n"));
     } else {
       setGroupName("");
       setEmailsInput("");
     }
-  }, [existingGroup, open]);
+  }, [existingGroup?.id, open]);
components/visitors/visitor-groups-section.tsx (1)

48-74: Clear editingGroup after deletion to prevent stale references.

If the deleted group happens to be the one stored in editingGroup, subsequent modal opens (e.g., via a rapid edit click before the list re-renders) could reference a deleted entity.

Suggested fix
       toast.success("Visitor group deleted successfully.");
       mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`);
+      setEditingGroup((prev) =>
+        prev?.id === group.id ? null : prev,
+      );
     } catch (error) {
pages/visitors/index.tsx (1)

26-31: useViewers fetches data even when the "groups" tab is active.

Consider conditionally fetching viewers only when the "visitors" tab is active (e.g., pass activeTab === "visitors" as an enabled flag), to avoid unnecessary API calls when the user is on the groups tab.

components/links/link-sheet/allow-list-section.tsx (1)

73-91: When re-enabling the allow list, emailProtected is forced true but emailAuthenticated is only set on enable, not cleared on disable.

On Line 81, enabling sets emailAuthenticated: true. But on Lines 84–89, disabling only clears allowList and visitorGroupIds — it does not reset emailAuthenticated back. This is likely intentional (email authentication is a separate setting the user may want to keep), but worth confirming that this asymmetry matches the desired UX.

Comment on lines 62 to 94
try {
const url = existingGroup
? `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${existingGroup.id}`
: `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`;

const response = await fetch(url, {
method: existingGroup ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: groupName.trim(), emails }),
});

if (!response.ok) {
const data = await response.json();
toast.error(data.error || "Failed to save visitor group.");
setLoading(false);
return;
}

toast.success(
existingGroup
? "Visitor group updated successfully!"
: "Visitor group created successfully!",
);

mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`,
);
} catch (error) {
toast.error("Error saving visitor group. Please try again.");
} finally {
setLoading(false);
setOpen(false);
}
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

Bug: finally block closes the modal even on API errors, discarding user input.

In JavaScript, finally always executes — even after the early return on line 77. So when the API returns a non-OK response, setOpen(false) on line 93 still runs, closing the modal and losing the user's input despite the error toast.

Move setOpen(false) and mutate into the success path only:

Proposed fix
     try {
       const url = existingGroup
         ? `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${existingGroup.id}`
         : `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`;
 
       const response = await fetch(url, {
         method: existingGroup ? "PUT" : "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ name: groupName.trim(), emails }),
       });
 
       if (!response.ok) {
         const data = await response.json();
         toast.error(data.error || "Failed to save visitor group.");
-        setLoading(false);
         return;
       }
 
       toast.success(
         existingGroup
           ? "Visitor group updated successfully!"
           : "Visitor group created successfully!",
       );
 
       mutate(
         `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`,
       );
+      setOpen(false);
     } catch (error) {
       toast.error("Error saving visitor group. Please try again.");
     } finally {
       setLoading(false);
-      setOpen(false);
     }
📝 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
try {
const url = existingGroup
? `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${existingGroup.id}`
: `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`;
const response = await fetch(url, {
method: existingGroup ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: groupName.trim(), emails }),
});
if (!response.ok) {
const data = await response.json();
toast.error(data.error || "Failed to save visitor group.");
setLoading(false);
return;
}
toast.success(
existingGroup
? "Visitor group updated successfully!"
: "Visitor group created successfully!",
);
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`,
);
} catch (error) {
toast.error("Error saving visitor group. Please try again.");
} finally {
setLoading(false);
setOpen(false);
}
try {
const url = existingGroup
? `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${existingGroup.id}`
: `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`;
const response = await fetch(url, {
method: existingGroup ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: groupName.trim(), emails }),
});
if (!response.ok) {
const data = await response.json();
toast.error(data.error || "Failed to save visitor group.");
return;
}
toast.success(
existingGroup
? "Visitor group updated successfully!"
: "Visitor group created successfully!",
);
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`,
);
setOpen(false);
} catch (error) {
toast.error("Error saving visitor group. Please try again.");
} finally {
setLoading(false);
}
🤖 Prompt for AI Agents
In `@components/visitors/visitor-group-modal.tsx` around lines 62 - 94, The
finally block currently calls setOpen(false) which closes the modal even when
the API returns an error; change control flow so that setOpen(false) and the
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`) call only run
on successful responses (after response.ok and toast.success), while keeping
setLoading(false) in finally to always clear loading; ensure the catch block
shows the error toast but does NOT call setOpen(false) or mutate so user input
is preserved when saving fails.

Comment on lines +424 to +444

// Update visitor groups (replace all)
if (linkData.visitorGroupIds !== undefined) {
// Delete existing visitor group associations
await tx.linkVisitorGroup.deleteMany({
where: { linkId: id },
});

// Create new associations
if (linkData.visitorGroupIds?.length > 0) {
await tx.linkVisitorGroup.createMany({
data: linkData.visitorGroupIds.map(
(visitorGroupId: string) => ({
linkId: id,
visitorGroupId,
}),
),
skipDuplicates: 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 | 🔴 Critical

Response returns stale visitorGroups after update.

The tx.link.update on Line 326 includes visitorGroups and captures the result in link. Then Lines 428–443 delete and re-create the visitor group associations. But the link object (and thus the response at Line 489) still contains the old visitorGroups from before the delete+create.

The client will receive stale visitor group data until a re-fetch, causing potential UI inconsistencies (e.g., the link sheet may show the wrong groups after saving).

Re-query the visitor groups after creating the new associations, or move the association management before the link update's include.

🐛 Proposed fix — re-query visitor groups after update
           await tx.linkVisitorGroup.createMany({
             data: linkData.visitorGroupIds.map(
               (visitorGroupId: string) => ({
                 linkId: id,
                 visitorGroupId,
               }),
             ),
             skipDuplicates: true,
           });
         }
+
+        // Re-query to get updated visitor groups
+        const updatedVisitorGroups = await tx.linkVisitorGroup.findMany({
+          where: { linkId: id },
+          select: { visitorGroupId: true },
+        });
+        link.visitorGroups = updatedVisitorGroups;
       }
🤖 Prompt for AI Agents
In `@pages/api/links/`[id]/index.ts around lines 424 - 444, The response returns
stale visitorGroups because tx.link.update captures the old related data into
the link variable before you delete/create associations with
tx.linkVisitorGroup.deleteMany and createMany; fix by ensuring the returned link
includes the post-update associations—either move the visitor-group
deletion/creation logic to run before calling tx.link.update (so its include
picks up the new associations) or, after createMany, re-query the link's
visitorGroups (e.g., refetch via tx.link.findUnique with include: {
visitorGroups: true }) and use that fresh result for the response instead of the
original link variable.

Comment on lines +426 to +443
if (linkData.visitorGroupIds !== undefined) {
// Delete existing visitor group associations
await tx.linkVisitorGroup.deleteMany({
where: { linkId: id },
});

// Create new associations
if (linkData.visitorGroupIds?.length > 0) {
await tx.linkVisitorGroup.createMany({
data: linkData.visitorGroupIds.map(
(visitorGroupId: string) => ({
linkId: id,
visitorGroupId,
}),
),
skipDuplicates: 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 | 🟠 Major

Same cross-team validation concern as the POST endpoint.

No validation that visitorGroupIds belong to the same teamId as the link being updated. See the comment on pages/api/links/index.ts Lines 240–251 for the suggested fix.

🤖 Prompt for AI Agents
In `@pages/api/links/`[id]/index.ts around lines 426 - 443, The update handler
currently deletes and recreates link-visitor group associations without
verifying the provided visitorGroupIds belong to the same team as the link;
before calling tx.linkVisitorGroup.deleteMany/createMany, fetch the existing
link (to get its teamId) and query tx.visitorGroup.findMany({ where: { id: { in:
linkData.visitorGroupIds }, teamId: link.teamId } }) and ensure the returned
count matches linkData.visitorGroupIds.length (or reject any ids not matching) —
if there are mismatches throw a validation error and abort the transaction; only
then proceed with tx.linkVisitorGroup.deleteMany and
tx.linkVisitorGroup.createMany.

Comment on lines +240 to +251
// Connect visitor groups for allow-list
...(linkData.visitorGroupIds?.length > 0 && {
visitorGroups: {
createMany: {
data: linkData.visitorGroupIds.map(
(visitorGroupId: string) => ({
visitorGroupId,
}),
),
},
},
}),
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

🧩 Analysis chain

🏁 Script executed:

fd -t f "\.ts$|\.prisma$" | head -20

Repository: mfts/papermark

Length of output: 989


🏁 Script executed:

find . -name "*.prisma" -o -name "schema.prisma"

Repository: mfts/papermark

Length of output: 375


🏁 Script executed:

ast-grep --pattern 'model VisitorGroup {
  $$$
}'

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

rg "model VisitorGroup" prisma/

Repository: mfts/papermark

Length of output: 104


🏁 Script executed:

cat prisma/schema/link.prisma

Repository: mfts/papermark

Length of output: 9827


🏁 Script executed:

cat -n pages/api/links/index.ts | sed -n '230,270p'

Repository: mfts/papermark

Length of output: 1593


🏁 Script executed:

cat -n pages/api/links/index.ts | sed -n '1,50p'

Repository: mfts/papermark

Length of output: 1749


🏁 Script executed:

cat -n pages/api/links/index.ts | sed -n '40,150p'

Repository: mfts/papermark

Length of output: 3687


🏁 Script executed:

rg "visitorGroupIds" pages/api/links/index.ts -B 3 -A 3

Repository: mfts/papermark

Length of output: 429


🏁 Script executed:

rg "visitorGroupIds" pages/api/links/ -B 5 -A 5

Repository: mfts/papermark

Length of output: 2063


🏁 Script executed:

cat -n pages/api/links/index.ts | sed -n '150,240p'

Repository: mfts/papermark

Length of output: 4424


🏁 Script executed:

cat -n pages/api/links/index.ts | sed -n '120,160p'

Repository: mfts/papermark

Length of output: 1539


🏁 Script executed:

rg "agreementId|groupId" pages/api/links/index.ts -B 2 -A 2

Repository: mfts/papermark

Length of output: 856


🏁 Script executed:

cat -n pages/api/links/\[id\]/index.ts | sed -n '1,50p'

Repository: mfts/papermark

Length of output: 1859


🏁 Script executed:

rg -A 20 "Update visitor groups" pages/api/links/\[id\]/index.ts

Repository: mfts/papermark

Length of output: 724


🏁 Script executed:

rg -B 50 "Update visitor groups" pages/api/links/\[id\]/index.ts | head -100

Repository: mfts/papermark

Length of output: 1760


🏁 Script executed:

wc -l pages/api/links/\[id\]/index.ts

Repository: mfts/papermark

Length of output: 91


🏁 Script executed:

cat -n pages/api/links/\[id\]/index.ts | sed -n '200,350p'

Repository: mfts/papermark

Length of output: 5438


🏁 Script executed:

cat -n pages/api/links/\[id\]/index.ts | sed -n '350,430p'

Repository: mfts/papermark

Length of output: 3548


🏁 Script executed:

cat -n pages/api/links/\[id\]/index.ts | sed -n '425,450p'

Repository: mfts/papermark

Length of output: 1078


🏁 Script executed:

rg "visitorGroup" pages/api/links/ -A 3 -B 3 | grep -i "team\|validate\|where" | head -30

Repository: mfts/papermark

Length of output: 120


🏁 Script executed:

rg "VisitorGroup" lib/ -l

Repository: mfts/papermark

Length of output: 87


🏁 Script executed:

cat lib/swr/use-visitor-groups.ts

Repository: mfts/papermark

Length of output: 741


🏁 Script executed:

rg "visitorGroup" lib/ -A 5 -B 5

Repository: mfts/papermark

Length of output: 1584


🏁 Script executed:

rg "visitor-groups" pages/api/ -l

Repository: mfts/papermark

Length of output: 165


🏁 Script executed:

rg "findMany.*VisitorGroup\|findUnique.*VisitorGroup" --type ts -A 10 -B 2

Repository: mfts/papermark

Length of output: 40


🏁 Script executed:

cat pages/api/teams/\[teamId\]/visitor-groups/index.ts

Repository: mfts/papermark

Length of output: 2728


🏁 Script executed:

rg "LinkVisitorGroup" prisma/schema/link.prisma -A 15

Repository: mfts/papermark

Length of output: 1193


🏁 Script executed:

rg "findMany.*visitorGroup\|where.*visitorGroupId" pages/api/links/ -A 5

Repository: mfts/papermark

Length of output: 40


Missing team-scoping validation for visitorGroupIds — cross-team data leak vulnerability.

The visitor group IDs from the request body are used directly without verifying they belong to the same teamId as the link being created or updated. A malicious user could associate visitor groups from another team with their link. While the FK constraint ensures the group exists, it doesn't enforce team ownership. This pattern appears in both POST (lines 240-251) and PATCH endpoints.

Add validation to confirm all provided visitorGroupIds belong to the requesting user's team before creating associations, consistent with how other entity IDs like agreementId and groupId are validated in this file.

🔒 Proposed fix
+            // Validate visitor groups belong to the same team
+            if (linkData.visitorGroupIds?.length > 0) {
+              const validGroups = await tx.visitorGroup.findMany({
+                where: {
+                  id: { in: linkData.visitorGroupIds },
+                  teamId: teamId,
+                },
+                select: { id: true },
+              });
+              if (validGroups.length !== linkData.visitorGroupIds.length) {
+                throw new Error("One or more visitor groups are invalid or do not belong to this team.");
+              }
+            }
             // Connect visitor groups for allow-list
             ...(linkData.visitorGroupIds?.length > 0 && {
               visitorGroups: {
🤖 Prompt for AI Agents
In `@pages/api/links/index.ts` around lines 240 - 251, Validate that each ID in
linkData.visitorGroupIds belongs to the requesting user's team before passing
them into the Prisma create/update payload (the visitorGroups: { createMany: {
data: ... } } block). Specifically, in both the POST and PATCH handlers where
linkData.visitorGroupIds is used, fetch the visitor groups by the provided IDs
(e.g., via a DB call filtering on id IN visitorGroupIds and teamId ===
request.teamId) and ensure the returned count matches the input count; if not,
throw a 400/403 error. Only after this check, map the validated visitorGroupIds
into the visitorGroups createMany data; keep the same variable names (linkData,
visitorGroupIds, visitorGroups) so the rest of the handler remains unchanged.

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (19)
.agents/skills/postgres/references/indexing.md-51-52 (1)

51-52: ⚠️ Potential issue | 🟡 Minor

metadata_idx violates the naming convention defined in the same file.

Line 57 specifies {table}_{column}_idx, but the example at line 52 uses metadata_idx (missing the table prefix order_). An AI agent applying this guide will produce an inconsistently named index.

📝 Proposed fix
-CREATE INDEX metadata_idx ON order USING GIN (metadata);       -- JSONB
+CREATE INDEX order_metadata_idx ON order USING GIN (metadata); -- JSONB
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/indexing.md around lines 51 - 52, The
index name metadata_idx does not follow the documented convention
{table}_{column}_idx; update the CREATE INDEX statement to use the
table-prefixed name (e.g., rename metadata_idx to order_metadata_idx) so the
index on table order for column metadata matches the naming rule; locate the
CREATE INDEX for metadata_idx and change its identifier to order_metadata_idx
(ensure any references to metadata_idx elsewhere are updated accordingly).
.agents/skills/postgres/references/index-optimization.md-45-45 (1)

45-45: ⚠️ Potential issue | 🟡 Minor

Section heading is incorrect — should be "Identify Duplicate Indexes".

The heading at line 45 repeats "Identify Unused Indexes," but the description ("Indexes with identical definitions … are duplicates") and the SQL query beneath it both target duplicate indexes, not unused ones. An AI agent processing this reference file will get a wrong signal about what the section covers.

📝 Proposed fix
-## Identify Unused Indexes
+## Identify Duplicate Indexes
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/index-optimization.md at line 45, The
section heading currently reads "Identify Unused Indexes" but the content and
SQL target duplicate indexes; update the heading text to "Identify Duplicate
Indexes" (replace the string "Identify Unused Indexes" in this section) and
verify any internal references or anchor links in the same file that point to
this heading are updated to the new text so the heading and content match; leave
the SQL and description unchanged.
.agents/skills/postgres/references/partitioning.md-47-56 (1)

47-56: ⚠️ Potential issue | 🟡 Minor

order is a reserved keyword — the SQL example will fail as written.

ORDER is reserved in both the SQL standard and PostgreSQL. CREATE TABLE order (...) without quoting the identifier produces a syntax error. Either quote it ("order") or, preferably, use a non-reserved name like orders so the example works out-of-the-box for anyone who copies it.

🐛 Proposed fix
-CREATE TABLE order (
+CREATE TABLE orders (
   id BIGINT GENERATED ALWAYS AS IDENTITY,
   region TEXT NOT NULL,
   total NUMERIC(10,2),
-  PRIMARY KEY (id, region) -- Partition key MUST be part of PK
-) PARTITION BY LIST (region);
+  PRIMARY KEY (id, region)  -- Partition key MUST be part of PK
+) PARTITION BY LIST (region);

-CREATE TABLE order_us PARTITION OF order FOR VALUES IN ('us');
-CREATE TABLE order_eu PARTITION OF order FOR VALUES IN ('eu');
-CREATE TABLE order_default PARTITION OF order DEFAULT;  -- catches unmatched values
+CREATE TABLE orders_us      PARTITION OF orders FOR VALUES IN ('us');
+CREATE TABLE orders_eu      PARTITION OF orders FOR VALUES IN ('eu');
+CREATE TABLE orders_default PARTITION OF orders DEFAULT;  -- catches unmatched values
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/partitioning.md around lines 47 - 56, The
SQL example uses the reserved identifier order which will cause syntax errors;
update the CREATE TABLE and partition declarations to use a non-reserved name
(e.g., orders) or consistently quote the identifier ("order") across all
references (PRIMARY KEY, PARTITION BY LIST, and partition tables order_us,
order_eu, order_default) so the DDL runs as-is; ensure the partition table names
and the parent table name match the chosen identifier.
.agents/skills/postgres/references/backup-recovery.md-24-30 (1)

24-30: ⚠️ Potential issue | 🟡 Minor

Add a blank line before the table to satisfy MD058.

The markdownlint rule MD058 requires tables to be surrounded by blank lines. Line 23 (## Tool Comparison) is immediately followed by the table header with no separating blank line.

📝 Proposed fix
 ## Tool Comparison
+
 | Tool | Use case |
 |------|----------|
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/backup-recovery.md around lines 24 - 30,
Add a blank line between the "## Tool Comparison" heading and the table so the
table is separated from the heading to satisfy markdownlint rule MD058; locate
the heading text "## Tool Comparison" in
.agents/skills/postgres/references/backup-recovery.md and insert a single empty
line before the table starting with "| Tool | Use case |".
.agents/skills/postgres/references/backup-recovery.md-15-15 (1)

15-15: ⚠️ Potential issue | 🟡 Minor

Two inaccuracies in the physical backup section may cause operational failures.

  1. Cross-architecture portability claim is misleading. PostgreSQL physical backups are not officially supported across different CPU architectures. The PostgreSQL docs require the same hardware architecture for a physical restore. Even though x86_64 and ARM64 (AArch64 in LE mode) are both little-endian, differences in OS page size, C struct alignment, and platform ABI can cause silent data corruption or a failed restore. Using pg_dump / pg_restore (logical) is the supported path for cross-architecture migrations.

  2. pg_backup_start() / pg_backup_stop() are PostgreSQL 15+ APIs. On PG ≤ 14 the functions are named pg_start_backup() / pg_stop_backup(). An agent following this doc against an older cluster will generate invalid SQL.

📝 Proposed fix
-Copies raw PGDATA; same major version and platform required; cross-architecture works if same endianness (e.g., x86_64 ↔ ARM64). Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative: `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (complex; must write returned `backup_label`).
+Copies raw PGDATA; **same major version and same hardware architecture required** — cross-architecture restores are not supported. Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative (PG 15+): `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (PG ≤14: `pg_start_backup()` / `pg_stop_backup()`); must write returned `backup_label`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/backup-recovery.md at line 15, Update the
physical backup note to remove the claim that cross-architecture restores are
supported; state that PostgreSQL requires the same CPU architecture for physical
restores (differences in page size, struct alignment, ABI can break restores)
and recommend using logical backup (pg_dump/pg_restore) for cross-architecture
migrations; also correct the API names: mention that pre-15 clusters use
pg_start_backup() / pg_stop_backup() while PostgreSQL 15+ exposes
pg_backup_start() / pg_backup_stop(), and advise the doc user to choose the
correct function based on their server version and to ensure the returned
backup_label is written when using the start/stop backup sequence.
.agents/skills/frontend-design/SKILL.md-4-4 (1)

4-4: ⚠️ Potential issue | 🟡 Minor

Remove hardcoded "Claude" reference from line 42.

Line 42 explicitly names "Claude" in a model-agnostic skill file, which creates inconsistency. Other SKILL.md files in the repository avoid model-specific language. Rewrite this to be model-neutral (e.g., "Remember: AI can produce extraordinary creative work...").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/frontend-design/SKILL.md at line 4, Replace the hardcoded
"Claude" reference on line 42 of SKILL.md with a model-neutral phrase; locate
the string "Claude" in .agents/skills/frontend-design/SKILL.md and change it to
a neutral wording such as "AI" or "an AI model" (for example: "Remember: AI can
produce extraordinary creative work...") so the skill file remains
model-agnostic and consistent with other SKILL.md files.
.agents/skills/postgres/references/ps-extensions.md-15-15 (1)

15-15: ⚠️ Potential issue | 🟡 Minor

Wording slightly overstates restart frequency.

The phrase "This often requires a database restart" implies most extensions trigger a restart. PlanetScale's own documentation uses "may require a database restart" and indicates only the specific subset of extensions marked with ✅ in the "Restart required" column need it. The current wording could cause an AI agent to routinely over-communicate restart warnings to users.

✏️ Suggested wording correction
-Some extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. This often requires a database restart.
+Some extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. Extensions marked as "Restart required" in the PlanetScale docs will require a database restart.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/ps-extensions.md at line 15, Change the
sentence "This often requires a database restart" to a more accurate phrase: use
"This may require a database restart" and add that only extensions marked with a
"Restart required" indicator in the PlanetScale dashboard (Clusters >
Extensions) require a restart; replace the original sentence in the
ps-extensions.md content to avoid implying restarts are frequent for most
extensions.
.agents/skills/postgres/references/replication.md-15-15 (1)

15-15: ⚠️ Potential issue | 🟡 Minor

idle_replication_slot_timeout was introduced in PG 18, not PG 17.

idle_replication_slot_timeout was added in PostgreSQL 18. The PG 18 release notes confirm it as a new feature: "Allow inactive replication slots to be automatically invalidated using server variable idle_replication_slot_timeout." The (PG 17+) annotation should be corrected to (PG 18+) to avoid misleading guidance on PG 17 clusters.

📝 Proposed fix
-Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 17+) to auto-invalidate idle slots.
+Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 18+) to auto-invalidate idle slots.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/replication.md at line 15, The
documentation incorrectly states that idle_replication_slot_timeout is available
in PG 17; update the text to say the setting was introduced in PostgreSQL 18 by
replacing the "(PG 17+)" annotation with "(PG 18+)" wherever
idle_replication_slot_timeout is mentioned so readers aren't misled about
availability; keep the surrounding guidance about slot behavior and WAL
retention unchanged.
.agents/skills/postgres/references/monitoring.md-16-16 (1)

16-16: ⚠️ Potential issue | 🟡 Minor

buffers_backend description is slightly imprecise.

The parenthetical "high = backends bypassing bgwriter" understates the concern. Those buffers_backend and buffers_backend_fsync columns were removed from pg_stat_bgwriter in PG 17, and the information moved to pg_stat_io. More precisely, a high buffers_backend means backend processes are directly flushing dirty pages from shared buffers under memory pressure, bypassing both the bgwriter and the checkpointer — the real signal is a too-small shared_buffers. The current wording could mislead a reader into tuning only bgwriter settings when the root fix is more shared memory.

✏️ Suggested wording
-- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends bypassing bgwriter). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`.
+- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends writing dirty pages directly under shared buffer pressure, bypassing bgwriter/checkpointer; fix: increase `shared_buffers`). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`; `buffers_backend` moved to `pg_stat_io`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/monitoring.md at line 16, Update the
pg_stat_bgwriter row to clarify that the removed columns `buffers_backend` and
`buffers_backend_fsync` (now exposed in `pg_stat_io`) indicate backend processes
directly flushing dirty pages under memory pressure, which means shared_buffers
is likely too small—not merely that the bgwriter is being bypassed; mention that
PG17 moved checkpoint stats to `pg_stat_checkpointer` and recommend tuning
`shared_buffers` (and investigating backend-driven flushes) rather than only
adjusting bgwriter settings. Reference `pg_stat_bgwriter`, `buffers_backend`,
`buffers_backend_fsync`, `pg_stat_io`, `pg_stat_checkpointer`, `shared_buffers`,
bgwriter and checkpointer in the updated wording.
.agents/skills/postgres/references/schema-design.md-11-11 (1)

11-11: ⚠️ Potential issue | 🟡 Minor

UUIDv7 leaks record creation time — add a security note.

Using UUIDv7 is generally discouraged for security when the primary key is exposed to end users in external-facing APIs, because UUIDv7 incorporates a 48-bit Unix timestamp, meaning the identifier itself leaks the record's creation time. This is relevant for applications like Papermark where document/link IDs surface in URLs. The guide should call this out so consumers can make an informed choice.

📝 Suggested note to add
 Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs (requires PostgreSQL ≥ 18).
+
+> **Security note:** UUIDv7 embeds a millisecond-precision Unix timestamp. Avoid exposing UUIDv7 primary keys directly in public-facing URLs or APIs where leaking record creation time is unacceptable.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/schema-design.md at line 11, Add a short
security note to the schema guidance calling out that UUIDv7 embeds a 48-bit
Unix timestamp and therefore leaks record creation time (relevant when IDs are
exposed in external-facing APIs such as Papermark document/link URLs); advise
consumers to avoid UUIDv7 for publicly visible primary keys and prefer BIGINT
GENERATED ALWAYS AS IDENTITY for sequential IDs or use non-time-based UUIDs only
when exposure is acceptable, and mention that UUIDv4 should also be avoided as
the default primary key strategy in favor of BIGINT identity when possible.
.agents/skills/postgres/references/wal-operations.md-11-11 (1)

11-11: ⚠️ Potential issue | 🟡 Minor

synchronous_commit caveat omitted — the fsync-on-commit guarantee is conditional.

"On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS" is only true with the default synchronous_commit = on. With synchronous_commit = off or local, WAL is not fsynced synchronously on commit, and the durability guarantee described here does not hold. Since this document is used as an operational reference, omitting this caveat could mislead an operator who needs to reason about durability settings.

📝 Suggested addition
-On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily.
+On COMMIT (with default `synchronous_commit=on`), PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily. With `synchronous_commit=off/local`, the fsync is skipped for performance; durability is weakened accordingly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/wal-operations.md at line 11, Update the
WAL description to clarify that the fsync-on-commit behavior is conditional on
the synchronous_commit setting: mention that "On COMMIT, PostgreSQL fsyncs WAL
to disk and returns SUCCESS" only applies when synchronous_commit = on (the
default); when synchronous_commit = off or local, WAL is not fsynced
synchronously on commit and the durability guarantee does not hold; reference
the synchronous_commit parameter and advise operators not to change it in
production without understanding the durability trade-offs (and keep the
existing warning about never disabling fsync).
.agents/skills/postgres/references/wal-operations.md-38-38 (1)

38-38: ⚠️ Potential issue | 🟡 Minor

pg_stat_io version attribution is inaccurate — it was introduced in PG16, not PG17.

The surrounding context groups the pg_stat_io note under "PG17+", implying it arrived alongside pg_stat_checkpointer. In reality, pg_stat_io was introduced in PostgreSQL 16. An agent or operator relying on this document could incorrectly assume querying pg_stat_io requires PG17, or conversely apply the PG17 assumption to a PG16 instance.

📝 Suggested clarification
-Backend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`) are now tracked in `pg_stat_io`: `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`
+Backend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`, removed in PG17) are tracked in `pg_stat_io` (available since PG16): `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/wal-operations.md at line 38, Update the
version note to attribute pg_stat_io to PostgreSQL 16 (not 17): change the
grouping or text that places the pg_stat_io example (the SELECT writes FROM
pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation') under
"PG17+" and instead mention that pg_stat_io was introduced in PG16 while
pg_stat_checkpointer is PG17; ensure references to pg_stat_bgwriter and the
example query remain but clarify the correct version for pg_stat_io to avoid
confusion.
.agents/skills/postgres/references/memory-management-ops.md-14-14 (1)

14-14: ⚠️ Potential issue | 🟡 Minor

hash_mem_multiplier default of 2.0 is only accurate for PostgreSQL 15+.

The parameter was introduced in PostgreSQL 13 with a default of 1.0; it remained 1.0 in PostgreSQL 14 and was raised to 2.0 in PostgreSQL 15. The document doesn't specify a minimum version, which makes the stated default potentially misleading for teams running PostgreSQL 13 or 14. Consider adding a version qualifier or pinning the document to a minimum supported version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/memory-management-ops.md at line 14, The
doc line about hash_mem_multiplier is inaccurate for older PostgreSQL versions;
update the text around hash_mem_multiplier to either (a) state version-specific
defaults (PostgreSQL 13–14 default 1.0; PostgreSQL 15+ default 2.0) or (b) add a
clear minimum-supported-version note that pins the doc to PostgreSQL 15+, so
readers on 13/14 aren't misled; reference and edit the `hash_mem_multiplier`
entry to include the chosen version qualifier and the correct default values.
.agents/skills/postgres/references/memory-management-ops.md-1-39 (1)

1-39: ⚠️ Potential issue | 🟡 Minor

Specify minimum PostgreSQL version for hash_mem_multiplier default value.

Line 14 states hash_mem_multiplier default is 2.0, but this is only accurate for PostgreSQL 15+. PostgreSQL 13–14 have a default of 1.0. Either add a minimum version requirement to the document header or update the statement to include all version defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/memory-management-ops.md around lines 1 -
39, The doc incorrectly states hash_mem_multiplier defaults to 2.0 without
version context; update the "Hash multiplier" bullet to mention version-specific
defaults (hash_mem_multiplier = 1.0 for PG 13–14, 2.0 starting in PG15) or add a
minimum PostgreSQL version in the header; reference the symbol
hash_mem_multiplier and adjust the sentence(s) in the "Hash multiplier" section
and/or the document header to clearly state the applicable PostgreSQL versions.
.agents/skills/postgres/references/query-patterns.md-34-38 (2)

34-38: ⚠️ Potential issue | 🟡 Minor

SARGable "Good" example contradicts the "SELECT specific columns" guideline

The "Good" variant fixes only the predicate but still uses SELECT *. An AI agent reading this example in isolation learns that SELECT * is acceptable in an optimized query, which directly undercuts the first guideline.

✏️ Proposed fix
 -- Bad: Full table scan
-SELECT * FROM user WHERE date_trunc('day', created_at) = '2023-01-01';
+SELECT id, name, email FROM users WHERE date_trunc('day', created_at) = '2023-01-01';
 -- Good: Index scan
-SELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
+SELECT id, name, email FROM users WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/query-patterns.md around lines 34 - 38,
The "Good: Index scan" example still uses "SELECT *", contradicting the
guideline to select specific columns; update the good example SQL (the line
starting with "SELECT * FROM user WHERE created_at >= '2023-01-01'...") to list
explicit columns (e.g., "SELECT id, created_at, <other_needed_columns> FROM user
WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02'") and add a brief
note in the same example clarifying that choosing only required columns
preserves the sargability benefit.

34-38: ⚠️ Potential issue | 🟡 Minor

SARGable "Good" example still uses SELECT *, contradicting the first guideline

The "Good" variant only fixes the predicate (range instead of date_trunc), but retains SELECT *. An AI agent reading this example in isolation will learn that SELECT * is acceptable in an optimized query, undercutting the explicit rule stated in the first section.

✏️ Proposed fix
 -- Good: Index scan
-SELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
+SELECT id, name, email FROM users WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/query-patterns.md around lines 34 - 38,
The "Good" SARGable example still shows SELECT * which contradicts the guideline
against SELECT *; update the example query that currently uses "SELECT * FROM
user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';" to use an
explicit column list (e.g., SELECT id, name, created_at ...) or a placeholder
like SELECT column1, column2, created_at ... so the example demonstrates both a
SARGable predicate (no date_trunc) and avoiding SELECT *; ensure the revised
example keeps the improved WHERE clause and replaces SELECT * with explicit
columns.
.agents/skills/postgres/references/query-patterns.md-12-25 (2)

12-25: ⚠️ Potential issue | 🟡 Minor

user and order are reserved PostgreSQL keywords — every example in these sections will produce a syntax error as written

In PostgreSQL, tokens labeled "reserved" are not allowed as column or table names. Both user and order fall in this category. Attempting to use an unquoted User as a table name in PostgreSQL raises SQLSTATE[42601]: Syntax error at or near "User", and an unquoted table named order likewise requires quoting as "order" or renaming.

Because this file is consumed by an AI coding agent as a canonical reference, the agent will reproduce these broken identifiers directly in generated SQL. The preferred fix is to rename to the conventional non-reserved plurals (users, orders) so no quoting is needed anywhere.

🛠️ Proposed fix — replace reserved identifiers throughout the file
-SELECT * FROM user WHERE status = 'active';
+SELECT * FROM users WHERE status = 'active';
 -- Good:
-SELECT id, name, email FROM user WHERE status = 'active';
+SELECT id, name, email FROM users WHERE status = 'active';
-SELECT id, (SELECT COUNT(*) FROM order WHERE order.user_id = user.id) FROM user;
+SELECT id, (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) FROM users;
 -- Good
-SELECT u.id, COUNT(o.id) FROM user u LEFT JOIN order o ON o.user_id = u.id GROUP BY u.id;
+SELECT u.id, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id;
-SELECT * FROM user WHERE date_trunc('day', created_at) = '2023-01-01';
+SELECT id, name FROM users WHERE date_trunc('day', created_at) = '2023-01-01';
 -- Good: Index scan
-SELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
+SELECT id, name FROM users WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
-    cursor.execute("SELECT name FROM user WHERE id = %s", (uid,))
+    cursor.execute("SELECT name FROM users WHERE id = %s", (uid,))
 # Good (Postgres specific)
-cursor.execute("SELECT id, name FROM user WHERE id = ANY(%s)", (list(user_ids),))
+cursor.execute("SELECT id, name FROM users WHERE id = ANY(%s)", (list(user_ids),))
 # Good (Standard SQL)
-# cursor.execute("SELECT id, name FROM user WHERE id IN %s", (tuple(user_ids),))
+# cursor.execute("SELECT id, name FROM users WHERE id IN %s", (tuple(user_ids),))
-for user in User.query.all():
-    print(user.posts)
-users = User.query.options(joinedload(User.posts)).all()
+for account in Account.query.all():
+    print(account.posts)
+accounts = Account.query.options(joinedload(Account.posts)).all()
-SELECT id, name FROM user u
-WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id AND o.total > 100);
+SELECT id, name FROM users u
+WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/query-patterns.md around lines 12 - 25,
Replace every occurrence of the reserved identifiers user and order in the SQL
examples with non-reserved plural names users and orders (e.g., change SELECT *
FROM user to SELECT * FROM users and the correlated subquery referencing
order.user_id to orders.user_id), updating all examples in the document
(SELECTs, JOINs, aliases like u and o, GROUP BY examples) so they remain
semantically identical but use users and orders; do not use quoted identifiers.

12-25: ⚠️ Potential issue | 🟡 Minor

user and order are PostgreSQL reserved keywords that require quoting or renaming

Both USER and ORDER are reserved keywords in PostgreSQL and cannot be used as unquoted table identifiers—any example copied verbatim will fail with a syntax error. Since this document serves as a reference for an AI coding agent, propagating broken SQL patterns here risks generating invalid code downstream.

Either quote these identifiers every time ("user", "order") or rename tables to non-reserved names (users, orders). Renaming is the cleaner approach and avoids quote escaping overhead.

Proposed fix — replace with non-reserved identifiers
-SELECT * FROM user WHERE status = 'active';
+SELECT * FROM users WHERE status = 'active';
-SELECT id, (SELECT COUNT(*) FROM order WHERE order.user_id = user.id) FROM user;
+SELECT id, (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) FROM users;
 -- Good
-SELECT u.id, COUNT(o.id) FROM user u LEFT JOIN order o ON o.user_id = u.id GROUP BY u.id;
+SELECT u.id, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/query-patterns.md around lines 12 - 25,
The SQL examples use reserved identifiers "user" and "order" which will cause
syntax errors; update all occurrences of the table names in this file (e.g., the
examples showing SELECT * FROM user, SELECT id, (SELECT COUNT(*) FROM order
...), JOIN examples using user and order) to non-reserved identifiers such as
users and orders (or consistently quote them) so the examples run as-is; ensure
both the single-row subquery example and the JOIN/GROUP BY example use the new
names (e.g., users and orders) throughout.
.agents/skills/postgres/references/ps-connections.md-19-21 (1)

19-21: ⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced code block.

The connection string block has no language tag, which triggers MD040. Using text or plaintext resolves it without implying shell execution.

📝 Proposed fix
-```
+```text
 postgresql://<user>:<password>@<host>.horizon.psdb.cloud:5432/<database>?sslmode=verify-full&sslrootcert=system&sslnegotiation=direct
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/ps-connections.md around lines 19 - 21,
Add a language identifier to the fenced code block containing the Postgres
connection string so the linter rule MD040 is satisfied; update the block that
currently contains
"postgresql://:@.horizon.psdb.cloud:5432/?sslmode=verify-full&sslrootcert=system&sslnegotiation=direct"
by changing the opening fence to include a plain language tag such as "text"
(e.g., ```text) to avoid implying shell execution.


</details>

</blockquote></details>

</blockquote></details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->


## Autovacuum Tuning

Triggers when dead tuples > `Min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)`. `autovacuum_vacuum_max_threshold` defaults to 100M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceeding `autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples * pct_not_frozen` (ensures insert-only tables get frozen; PG 13+). For large/hot tables, set per-table overrides:
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

🧩 Analysis chain

🌐 Web query:

PostgreSQL 18 autovacuum_vacuum_max_threshold parameter documentation

💡 Result:

In PostgreSQL 18, autovacuum_vacuum_max_threshold is documented in “19.10. Vacuuming → 19.10.1 Automatic Vacuuming” as:

  • Purpose: Sets the maximum number of updated/deleted tuples that can trigger an autovacuum VACUUM for a table—i.e., it caps the trigger value computed from autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples.
  • Default: 100000000 tuples.
  • Disable cap: Set to -1.
  • Scope: Set in postgresql.conf / server command line, and can be overridden per-table via table storage parameters. [1]

Per-table storage-parameter support is also listed in the CREATE TABLE / storage parameters documentation (usable via ALTER TABLE ... SET (autovacuum_vacuum_max_threshold = ...)). [2]

Sources: PostgreSQL 18 docs (Vacuuming / Automatic Vacuuming) [1], PostgreSQL docs (table storage parameters) [2].

[1] (postgresql.org)
[2] (postgresql.org)

Citations:


🌐 Web query:

PostgreSQL autovacuum_vacuum_insert_threshold pct_not_frozen parameter

💡 Result:

autovacuum_vacuum_insert_threshold / “insert-triggered” autovacuum

Since PostgreSQL 13, autovacuum can be triggered by inserts (useful for insert-only / append-only tables). Autovacuum runs when inserts since last vacuum exceed:

vacuum_insert_threshold =
  autovacuum_vacuum_insert_threshold
  + autovacuum_vacuum_insert_scale_factor * reltuples

reltuples is the planner’s row estimate for the table. (postgresql.org)

Notes:

  • Default values are commonly autovacuum_vacuum_insert_threshold = 1000 and autovacuum_vacuum_insert_scale_factor = 0.2 (PG13+). (cybertec-postgresql.com)
  • You can disable insert-triggered vacuums per table by setting autovacuum_vacuum_insert_threshold = -1 as a storage parameter. (cybertec-postgresql.com)

What is pct_not_frozen?

pct_not_frozen is not a built-in PostgreSQL GUC/parameter. It’s typically a derived metric used by monitoring scripts/tools to express “how close are we to an anti-wraparound (aggressive) vacuum”, based on XID age.

A common way to compute something equivalent is using age(relfrozenxid) (or age(datfrozenxid) at the DB level) relative to autovacuum_freeze_max_age. The PostgreSQL docs explain relfrozenxid/datfrozenxid and how they’re used to track freezing progress. (postgresql.org)

Example (table-level “percent of freeze horizon consumed” — often what people mean by “% not frozen”):

SELECT
  c.oid::regclass AS table_name,
  age(c.relfrozenxid) AS xid_age,
  round(100.0 * age(c.relfrozenxid)
        / current_setting('autovacuum_freeze_max_age')::numeric, 2) AS pct_of_freeze_horizon_consumed
FROM pg_class c
WHERE c.relkind IN ('r','m');

(Variants of this “percent consumed” approach are also shown in vendor guidance for wraparound monitoring.) (cloud.google.com)

Citations:


Remove pct_not_frozen from the insert-trigger formula—it is not a PostgreSQL parameter.

The insert-based autovacuum trigger formula in this line is incorrect. The actual formula per PostgreSQL 13+ documentation is:

autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples

The line currently includes * pct_not_frozen at the end, which is neither a real PostgreSQL parameter nor part of the actual trigger condition. Remove it to avoid misleading agents that consume this skill file. (autovacuum_vacuum_max_threshold and its PG 18+ default of 100M are correct and do not need changes.)

🧰 Tools
🪛 LanguageTool

[style] ~19-~19: As an alternative to the over-used intensifier ‘very’, consider replacing this phrase.
Context: ...00M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceed...

(EN_WEAK_ADJECTIVE)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/mvcc-vacuum.md at line 19, The
insert-trigger formula wrongly includes a non-existent parameter pct_not_frozen;
update the sentence that describes the autovacuum insert trigger to use the
correct PostgreSQL 13+ formula: "autovacuum_vacuum_insert_threshold +
autovacuum_vacuum_insert_scale_factor * reltuples" by removing "*
pct_not_frozen" and leave the surrounding text about
autovacuum_vacuum_max_threshold and its default unchanged; ensure you reference
autovacuum_vacuum_insert_threshold, autovacuum_vacuum_insert_scale_factor, and
reltuples in the corrected line.


## Primary Keys

Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.
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

🌐 Web query:

When was uuidv7() function introduced in PostgreSQL? Which versions support it?

💡 Result:

uuidv7() was added as a built-in UUID generation function during the PostgreSQL 18 development cycle (merged on 2024-12-11). [1]

It is supported in PostgreSQL 18 and later (18+); it is not available as a built-in function in PostgreSQL 17 or earlier. [2][3]

Supported versions: PostgreSQL 18+. [2]

Sources: PostgreSQL 18 UUID functions documentation (shows uuidv7) [2]; pgsql-committers commit message adding uuidv7() dated 2024-12-11 [1]; background note that PostgreSQL 17 didn’t have native UUIDv7 generation [3].


🏁 Script executed:

# Read the schema-design.md file to verify its content
cat -n .agents/skills/postgres/references/schema-design.md

Repository: mfts/papermark

Length of output: 2469


🏁 Script executed:

# Verify PostgreSQL reserved keywords - user and order
# Using postgres documentation or language specs
python3 << 'EOF'
# PostgreSQL reserved keywords - checking documentation
# These are well-known reserved words in PostgreSQL
reserved_keywords = {
    'user': True,  # reserved in PostgreSQL
    'order': True,  # reserved in PostgreSQL
}
print("PostgreSQL Reserved Keywords (relevant to this file):")
print("'user' is a reserved keyword: TRUE")
print("'order' is a reserved keyword: TRUE")
print("\nUsage as table names without escaping will cause syntax errors.")
EOF

Repository: mfts/papermark

Length of output: 247


Fix PostgreSQL version requirement for uuidv7() and remove reserved keywords from code examples.

The documentation contains two critical issues with the schema examples:

  1. uuidv7() requires PostgreSQL 18+: This function was added in PostgreSQL 18 (December 2024) and is not available in PostgreSQL 13–17. An AI agent or developer following this guide on a pre-18 cluster will encounter "function uuidv7() does not exist" errors. The guide should specify the minimum version or provide a fallback (pg_uuidv7 extension).

  2. Reserved keywords used as table names: The code examples use user and order as table names (lines 14, 34, 47), which are PostgreSQL reserved keywords. These DDL statements will fail with syntax errors. The guide's own naming convention section (line 56) recommends user_account and order_item—examples should follow that convention.

📝 Suggested fixes
-CREATE TABLE user (
+CREATE TABLE user_account (
   id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
   email TEXT NOT NULL UNIQUE
 );
-Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.
+Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs (requires **PostgreSQL ≥ 18**; on older versions install the [`pg_uuidv7`](https://github.com/fboulnois/pg_uuidv7) extension).
-CREATE TABLE order (
+CREATE TABLE order_item (
📝 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
Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.
Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs (requires **PostgreSQL ≥ 18**; on older versions install the [`pg_uuidv7`](https://github.com/fboulnois/pg_uuidv7) extension).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/schema-design.md at line 11, Update the
schema docs to state that uuidv7() requires PostgreSQL 18+ (or instruct users to
install the pg_uuidv7 extension when on older servers) and change example DDL to
avoid reserved keywords: replace tables named user and order with user_account
and order_item (and update any references to those names in CREATE TABLE, FK
definitions, and examples). Also show the alternative pattern (BIGINT GENERATED
ALWAYS AS IDENTITY) as the preferred PK and note uuidv7() usage only when
PostgreSQL 18+ or with the pg_uuidv7 extension.

Comment on lines +13 to +18
```sql
CREATE TABLE user (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
```
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

user and order are PostgreSQL reserved keywords — the SQL examples will fail as written.

user (line 14) and order (lines 34, 47) are reserved words in PostgreSQL. Executing CREATE TABLE user (...) or CREATE TABLE order (...) without double-quoting raises a syntax error. Because this guide is consumed by AI agents to generate real DDL, broken example SQL is a significant hazard.

Additionally, these names contradict the guide's own Naming Conventions section (line 56) which recommends compound snake_case identifiers like user_account and order_item.

Fix all three examples to use non-reserved, compound names that comply with the documented convention:

🐛 Proposed fix for all three SQL blocks
 ## Primary Keys
 
 ```sql
-CREATE TABLE user (
+CREATE TABLE user_account (
   id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
   email TEXT NOT NULL UNIQUE
 );

Data Types

-CREATE TABLE order (
+CREATE TABLE customer_order (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  status TEXT NOT NULL CHECK (status IN ('pending', 'shipped', 'delivered'))
);

Foreign Keys

-CREATE TABLE order (
+CREATE TABLE customer_order (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  customer_id BIGINT NOT NULL REFERENCES customer(id) ON DELETE CASCADE
+  customer_id BIGINT NOT NULL REFERENCES user_account(id) ON DELETE CASCADE
);
-CREATE INDEX order_customer_id_idx ON order (customer_id);
+CREATE INDEX customer_order_customer_id_idx ON customer_order (customer_id);

Also applies to: 33-38, 46-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/postgres/references/schema-design.md around lines 13 - 18,
The SQL examples use reserved PostgreSQL identifiers "user" and "order" and
violate the guide's Naming Conventions; update all three SQL blocks to use
non-reserved, compound snake_case names (e.g., rename table "user" →
"user_account", rename "order" → "customer_order"), update any foreign key
references (e.g., REFERENCES user(id) → REFERENCES user_account(id)), and rename
related indexes (e.g., order_customer_id_idx → customer_order_customer_id_idx)
so the examples compile and follow the documented convention.

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

🧹 Nitpick comments (3)
components/visitors/visitor-groups-section.tsx (3)

21-22: Combine the two swr imports into one.

mutate and useSWR are imported via two separate statements from the same package.

♻️ Proposed fix
-import { mutate } from "swr";
-import useSWR from "swr";
+import useSWR, { mutate } from "swr";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 21 - 22, The
file currently has two separate imports from the same package; combine them into
a single import statement so both mutate and useSWR are imported together from
"swr" (replace the two lines importing mutate and useSWR with one: import {
mutate, useSWR } from "swr";), ensuring any other references to mutate or useSWR
remain unchanged.

399-410: Use the already-extracted teamId constant instead of re-reading teamInfo?.currentTeam?.id.

teamId is declared at line 376 but handleDelete reads teamInfo?.currentTeam?.id twice. If teamId is undefined, the fetch will produce the URL /api/teams/undefined/visitor-groups/…. Consider guarding early:

♻️ Proposed fix
  const handleDelete = async (group: VisitorGroupWithCount) => {
+   if (!teamId) return;
    if (!confirm(`Are you sure you want to delete "${group.name}"? ...`)) return;

    try {
      const response = await fetch(
-       `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${group.id}`,
+       `/api/teams/${teamId}/visitor-groups/${group.id}`,
        { method: "DELETE" },
      );
      ...
-     mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`);
+     mutate(`/api/teams/${teamId}/visitor-groups`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 399 - 410, In
handleDelete, stop re-reading teamInfo?.currentTeam?.id and use the
already-extracted teamId constant for both the fetch URL and the mutate key; add
an early guard that returns (and optionally shows an error) if teamId is falsy
before calling fetch so you never construct /api/teams/undefined/..., and update
the mutate call to mutate(`/api/teams/${teamId}/visitor-groups`) to match.

388-414: Replace window.confirm() with a proper confirmation dialog.

window.confirm() is a synchronous, blocking browser dialog that cannot be styled, is suppressed in some browser contexts (e.g., cross-origin iframes), and generally delivers poor UX. A shadcn AlertDialog (already available in the project's UI library) or similar component would be more consistent with the rest of the design system.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 388 - 414, The
handler handleDelete currently uses a blocking window.confirm; replace that
synchronous prompt with the project's shadcn AlertDialog component by moving the
confirmation UI out of handleDelete into a non-blocking dialog flow: create
dialog state (open/close) for selecting a VisitorGroupWithCount, render
AlertDialog when a delete button is clicked (pass the group data to the dialog),
and only call the existing delete logic (the fetch + toast + mutate flow inside
handleDelete) after the user confirms in the AlertDialog; ensure the dialog
provides cancel/confirm actions, handles loading/disabled state while the async
delete runs, and closes on success or failure so the rest of handleDelete
(including mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`) and
toast.* calls) can remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/visitors/visitor-groups-section.tsx`:
- Around line 282-299: The shared `content` currently always includes
`ExternalLinkIcon`, which makes the non-navigable fallback `<div>` (rendered
when `documentId`/`dataroomId` is null) appear clickable; change the render so
the icon is only shown for actual links: either split `content` into two
variants (e.g., `contentWithIcon` and `contentNoIcon`) or keep `content` but
conditionally render `ExternalLinkIcon` only inside the `Link` branch that uses
`groupLink.id` and `documentId`; apply the same conditional/icon-splitting fix
to the other similar block around the `317-336` range.
- Around line 118-128: The two interactive divs using role="button" (the main
header that calls toggle and the chevron button at the bottom) are missing
aria-expanded; update both elements to include aria-expanded={expanded} so
screen readers can detect the open/closed state, and add distinct aria-labels
(e.g., aria-label="Visitor group header" and aria-label="Toggle details") if
needed to differentiate them; ensure you update the header onClick/onKeyDown
block that references toggle and the chevron role="button" block (the other
occurrence around the chevron) to match.
- Around line 103-104: The header is shown during loading because hasLinkedItems
is true whenever loadingDetails is true; change the condition so the "Links"
section is gated by the group's known linkCount when available — e.g., compute
hasLinkedItems using group.linkCount > 0 OR (loadingDetails &&
(documentLinks.length > 0 || dataroomLinks.length > 0)) so you don't flash
"Links (0)" while details are still loading; update the code that uses
hasLinkedItems in visitor-groups-section.tsx (references: hasLinkedItems,
loadingDetails, documentLinks, dataroomLinks, group.linkCount).

---

Nitpick comments:
In `@components/visitors/visitor-groups-section.tsx`:
- Around line 21-22: The file currently has two separate imports from the same
package; combine them into a single import statement so both mutate and useSWR
are imported together from "swr" (replace the two lines importing mutate and
useSWR with one: import { mutate, useSWR } from "swr";), ensuring any other
references to mutate or useSWR remain unchanged.
- Around line 399-410: In handleDelete, stop re-reading
teamInfo?.currentTeam?.id and use the already-extracted teamId constant for both
the fetch URL and the mutate key; add an early guard that returns (and
optionally shows an error) if teamId is falsy before calling fetch so you never
construct /api/teams/undefined/..., and update the mutate call to
mutate(`/api/teams/${teamId}/visitor-groups`) to match.
- Around line 388-414: The handler handleDelete currently uses a blocking
window.confirm; replace that synchronous prompt with the project's shadcn
AlertDialog component by moving the confirmation UI out of handleDelete into a
non-blocking dialog flow: create dialog state (open/close) for selecting a
VisitorGroupWithCount, render AlertDialog when a delete button is clicked (pass
the group data to the dialog), and only call the existing delete logic (the
fetch + toast + mutate flow inside handleDelete) after the user confirms in the
AlertDialog; ensure the dialog provides cancel/confirm actions, handles
loading/disabled state while the async delete runs, and closes on success or
failure so the rest of handleDelete (including
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`) and toast.*
calls) can remain unchanged.

Comment on lines +103 to +104
const hasLinkedItems =
loadingDetails || documentLinks.length > 0 || dataroomLinks.length > 0;
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

"Links (0)" header is shown while details are still loading.

hasLinkedItems is true whenever loadingDetails is true, so the "Links" section with a count of (0) is rendered immediately on expansion before the data arrives. Consider incorporating the group's known linkCount to gate the section header, which avoids a confusing zero-count flash.

♻️ Proposed fix
-const hasLinkedItems =
-  loadingDetails || documentLinks.length > 0 || dataroomLinks.length > 0;
+const hasLinkedItems =
+  (linkCount > 0 && loadingDetails) ||
+  documentLinks.length > 0 ||
+  dataroomLinks.length > 0;
📝 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 hasLinkedItems =
loadingDetails || documentLinks.length > 0 || dataroomLinks.length > 0;
const hasLinkedItems =
(linkCount > 0 && loadingDetails) ||
documentLinks.length > 0 ||
dataroomLinks.length > 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 103 - 104, The
header is shown during loading because hasLinkedItems is true whenever
loadingDetails is true; change the condition so the "Links" section is gated by
the group's known linkCount when available — e.g., compute hasLinkedItems using
group.linkCount > 0 OR (loadingDetails && (documentLinks.length > 0 ||
dataroomLinks.length > 0)) so you don't flash "Links (0)" while details are
still loading; update the code that uses hasLinkedItems in
visitor-groups-section.tsx (references: hasLinkedItems, loadingDetails,
documentLinks, dataroomLinks, group.linkCount).

Comment on lines +118 to +128
<div
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
className="cursor-pointer px-4 pt-3.5 sm:px-5 sm:pt-4"
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

Both toggle role="button" divs are missing aria-expanded.

Screen readers cannot determine the expanded/collapsed state without aria-expanded. Both interactive regions should reflect the current state, and optionally an aria-label to distinguish them from each other.

♻️ Proposed fix
-      role="button"
-      tabIndex={0}
-      onClick={toggle}
+      role="button"
+      tabIndex={0}
+      aria-expanded={expanded}
+      aria-label={`${group.name} details`}
+      onClick={toggle}

Apply the same aria-expanded={expanded} to the chevron role="button" at the bottom of the card.

Also applies to: 347-357

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 118 - 128, The
two interactive divs using role="button" (the main header that calls toggle and
the chevron button at the bottom) are missing aria-expanded; update both
elements to include aria-expanded={expanded} so screen readers can detect the
open/closed state, and add distinct aria-labels (e.g., aria-label="Visitor group
header" and aria-label="Toggle details") if needed to differentiate them; ensure
you update the header onClick/onKeyDown block that references toggle and the
chevron role="button" block (the other occurrence around the chevron) to match.

Comment on lines +282 to +299
</>
);
return documentId ? (
<Link
key={groupLink.id}
href={`/documents/${documentId}`}
onClick={(e) => e.stopPropagation()}
className="group/link flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800"
>
{content}
</Link>
) : (
<div
key={groupLink.id}
className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 dark:border-gray-700 dark:bg-gray-800/50"
>
{content}
</div>
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

ExternalLinkIcon is misleading in the non-navigable fallback <div>.

When documentId (or dataroomId) is null, the shared content variable — which includes ExternalLinkIcon — is rendered inside a plain <div>. The icon visually implies the item is clickable/navigable, but it isn't.

♻️ Proposed fix

Omit the icon in the fallback render, for example by splitting content or conditionally rendering the icon only inside the <Link> branch:

-                      const content = (
-                        <>
-                          <div className="flex min-w-0 items-center gap-2">
-                            ...
-                          </div>
-                          <ExternalLinkIcon className="ml-2 h-3 w-3 ..." />
-                        </>
-                      );
+                      const innerContent = (
+                        <div className="flex min-w-0 items-center gap-2">
+                          ...
+                        </div>
+                      );
                       return documentId ? (
                         <Link ...>
-                          {content}
+                          {innerContent}
+                          <ExternalLinkIcon className="ml-2 h-3 w-3 ..." />
                         </Link>
                       ) : (
                         <div ...>
-                          {content}
+                          {innerContent}
                         </div>
                       );

Also applies to: 317-336

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/visitors/visitor-groups-section.tsx` around lines 282 - 299, The
shared `content` currently always includes `ExternalLinkIcon`, which makes the
non-navigable fallback `<div>` (rendered when `documentId`/`dataroomId` is null)
appear clickable; change the render so the icon is only shown for actual links:
either split `content` into two variants (e.g., `contentWithIcon` and
`contentNoIcon`) or keep `content` but conditionally render `ExternalLinkIcon`
only inside the `Link` branch that uses `groupLink.id` and `documentId`; apply
the same conditional/icon-splitting fix to the other similar block around the
`317-336` range.

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