Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions components/datarooms/dataroom-search-results.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Link from "next/link";
import { useRouter } from "next/router";

import { TeamContextType } from "@/context/team-context";
import { ChevronRight, FileIcon, FolderIcon } from "lucide-react";

import {
DataroomFolderDocumentWithPath,
DataroomFolderWithCountAndPath,
} from "@/lib/swr/use-dataroom";
import { safeSlugify } from "@/lib/utils";

import DataroomDocumentCard from "@/components/datarooms/dataroom-document-card";
import FolderCard from "@/components/documents/folder-card";

function FolderPathBreadcrumb({
folderPath,
dataroomId,
}: {
folderPath: string[];
dataroomId: string;
}) {
const router = useRouter();
const { id } = router.query as { id: string };

const buildLink = (index: number) => {
if (index < 0) return `/datarooms/${id}/documents`;
const slugs = folderPath.slice(0, index + 1).map(safeSlugify);
return `/datarooms/${id}/documents/${slugs.join("/")}`;
};

return (
<div className="mt-1 flex flex-wrap items-center gap-x-1 text-xs leading-5 text-muted-foreground">
<p className="flex items-center gap-x-1">
<FolderIcon className="h-3 w-3" />
<Link
href={`/datarooms/${id}/documents`}
onClick={(e) => e.stopPropagation()}
className="relative z-10 hover:underline"
>
Root
</Link>
</p>
{folderPath.map((name, index) => (
<p key={index} className="flex items-center gap-x-1">
<ChevronRight className="h-3 w-3" />
<FolderIcon className="h-3 w-3" />
<Link
href={buildLink(index)}
onClick={(e) => e.stopPropagation()}
className="relative z-10 hover:underline"
>
{name}
</Link>
</p>
))}
</div>
);
}

export function DataroomSearchResults({
documents,
folders,
teamInfo,
dataroomId,
searchQuery,
}: {
documents: DataroomFolderDocumentWithPath[];
folders: DataroomFolderWithCountAndPath[];
teamInfo: TeamContextType | null;
dataroomId: string;
searchQuery: string;
}) {
const totalResults = documents.length + folders.length;

return (
<div className="space-y-4">
<div className="flex items-center gap-x-2 text-sm text-muted-foreground">
<span>
Search results for &quot;{searchQuery}&quot;
</span>
<span className="text-xs">
({totalResults} result{totalResults !== 1 ? "s" : ""})
</span>
</div>

{totalResults === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<p className="text-sm text-muted-foreground">
No documents or folders found matching &quot;{searchQuery}&quot;
</p>
</div>
) : (
<ul role="list" className="space-y-4">
{folders.length > 0 && (
<>
<li className="flex items-center gap-x-1 text-sm text-muted-foreground">
<FolderIcon className="h-4 w-4" />
<span>
{folders.length} folder{folders.length !== 1 ? "s" : ""}
</span>
</li>
{folders.map((folder) => (
<li key={`folder-${folder.id}`}>
<div>
<FolderCard
folder={folder}
teamInfo={teamInfo}
isDataroom={true}
dataroomId={dataroomId}
/>
{folder.folderPath.length > 0 && (
<div className="ml-4 mt-1">
<FolderPathBreadcrumb
folderPath={folder.folderPath}
dataroomId={dataroomId}
/>
</div>
)}
</div>
</li>
))}
</>
)}

{documents.length > 0 && (
<>
<li className="flex items-center gap-x-1 text-sm text-muted-foreground">
<FileIcon className="h-4 w-4" />
<span>
{documents.length} document
{documents.length !== 1 ? "s" : ""}
</span>
</li>
{documents.map((doc) => (
<li key={`document-${doc.id}`}>
<div>
<DataroomDocumentCard
document={doc}
teamInfo={teamInfo}
dataroomId={dataroomId}
/>
{doc.folderPath.length > 0 && (
<div className="ml-4 mt-1">
<FolderPathBreadcrumb
folderPath={doc.folderPath}
dataroomId={dataroomId}
/>
</div>
)}
</div>
</li>
))}
</>
)}
</ul>
)}
</div>
);
}
40 changes: 40 additions & 0 deletions lib/swr/use-dataroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,43 @@ export function useDataroomVisitHistory({
error,
};
}

export type DataroomFolderWithCountAndPath = DataroomFolderWithCount & {
folderPath: string[];
};

export type DataroomFolderDocumentWithPath = DataroomFolderDocument & {
folderPath: string[];
};

export function useDataroomSearch({ query }: { query: string }) {
const router = useRouter();
const { id } = router.query as { id: string };
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;

const { data, error } = useSWR<{
documents: DataroomFolderDocumentWithPath[];
folders: DataroomFolderWithCountAndPath[];
}>(
teamId &&
id &&
query &&
query.trim().length > 0 &&
`/api/teams/${teamId}/datarooms/${id}/search?query=${encodeURIComponent(query.trim())}`,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);

const isLoading = query.trim().length > 0 && !data && !error;

return {
documents: data?.documents || [],
folders: data?.folders || [],
isLoading,
error,
};
}
173 changes: 173 additions & 0 deletions pages/api/teams/[teamId]/datarooms/[id]/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { NextApiRequest, NextApiResponse } from "next";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";

import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";

export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
res.setHeader("Allow", ["GET"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}

const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}

const userId = (session.user as CustomUser).id;
const {
teamId,
id: dataroomId,
query,
} = req.query as {
teamId: string;
id: string;
query?: string;
};

if (!query || query.trim().length === 0) {
return res.status(200).json({ documents: [], folders: [] });
}

try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: { userId },
},
},
});

if (!team) {
return res.status(401).end("Unauthorized");
}
Comment on lines +38 to +50
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

Bind the authorization check to the dataroom.

This route proves the caller belongs to teamId, but the two searches below are scoped only by dataroomId. A user who belongs to any team can pair that team's id with another team's dataroom id and enumerate foreign document/folder names. Please verify that dataroomId belongs to the authorized team before running the search.

Also applies to: 52-122

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

In `@pages/api/teams/`[teamId]/datarooms/[id]/search.ts around lines 38 - 50,
You're currently authorizing by team membership using prisma.team.findUnique but
not verifying the dataroom belongs to that team; before performing the two
searches (those that use dataroomId), query the dataroom record with the
provided dataroomId and teamId (e.g., via prisma.dataroom.findFirst/findUnique
where id = dataroomId and teamId = teamId) and return 401/403 if not found, then
proceed with the existing search logic so only datarooms tied to the authorized
team can be enumerated.


const [documents, folders] = await Promise.all([
prisma.dataroomDocument.findMany({
where: {
dataroomId,
document: {
name: {
contains: query.trim(),
mode: "insensitive",
},
},
},
orderBy: [
{ orderIndex: "asc" },
{ document: { name: "asc" } },
],
select: {
id: true,
dataroomId: true,
folderId: true,
orderIndex: true,
hierarchicalIndex: true,
createdAt: true,
updatedAt: true,
document: {
select: {
id: true,
name: true,
type: true,
advancedExcelEnabled: true,
versions: {
select: { id: true, hasPages: true },
},
isExternalUpload: true,
_count: {
select: {
views: { where: { dataroomId } },
versions: true,
},
},
},
},
},
}),

prisma.dataroomFolder.findMany({
where: {
dataroomId,
name: {
contains: query.trim(),
mode: "insensitive",
},
},
orderBy: [{ orderIndex: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
path: true,
parentId: true,
dataroomId: true,
orderIndex: true,
hierarchicalIndex: true,
icon: true,
color: true,
createdAt: true,
updatedAt: true,
_count: {
select: { documents: true, childFolders: true },
},
},
}),
]);

// Build breadcrumb paths for documents that are in folders
const folderIds = [
...new Set(documents.filter((d) => d.folderId).map((d) => d.folderId!)),
];

const allFoldersInDataroom =
folderIds.length > 0
? await prisma.dataroomFolder.findMany({
where: { dataroomId },
select: { id: true, name: true, path: true, parentId: true },
})
: [];

const folderMap = new Map(
allFoldersInDataroom.map((f) => [f.id, f]),
);

const buildBreadcrumb = (folderId: string): string[] => {
const names: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = folderMap.get(currentId);
if (!folder) break;
names.unshift(folder.name);
currentId = folder.parentId;
}
return names;
};

const documentsWithPath = documents.map((doc) => ({
...doc,
folderPath: doc.folderId ? buildBreadcrumb(doc.folderId) : [],
}));

const foldersWithPath = folders.map((folder) => ({
...folder,
folderPath: folder.parentId ? buildBreadcrumb(folder.parentId) : [],
Comment on lines +124 to +160
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

Include folder matches when building the breadcrumb map.

allFoldersInDataroom is only loaded when a matched document has a folderId, but foldersWithPath also calls buildBreadcrumb(). If a search returns nested folders and no documents, every folder result gets folderPath: [], so the breadcrumb UI disappears.

Suggested fix
-    const folderIds = [
-      ...new Set(documents.filter((d) => d.folderId).map((d) => d.folderId!)),
-    ];
+    const folderIds = new Set<string>();
+    for (const doc of documents) {
+      if (doc.folderId) folderIds.add(doc.folderId);
+    }
+    for (const folder of folders) {
+      if (folder.parentId) folderIds.add(folder.parentId);
+    }

     const allFoldersInDataroom =
-      folderIds.length > 0
+      folderIds.size > 0
         ? await prisma.dataroomFolder.findMany({
             where: { dataroomId },
             select: { id: true, name: true, path: true, parentId: 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
// Build breadcrumb paths for documents that are in folders
const folderIds = [
...new Set(documents.filter((d) => d.folderId).map((d) => d.folderId!)),
];
const allFoldersInDataroom =
folderIds.length > 0
? await prisma.dataroomFolder.findMany({
where: { dataroomId },
select: { id: true, name: true, path: true, parentId: true },
})
: [];
const folderMap = new Map(
allFoldersInDataroom.map((f) => [f.id, f]),
);
const buildBreadcrumb = (folderId: string): string[] => {
const names: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = folderMap.get(currentId);
if (!folder) break;
names.unshift(folder.name);
currentId = folder.parentId;
}
return names;
};
const documentsWithPath = documents.map((doc) => ({
...doc,
folderPath: doc.folderId ? buildBreadcrumb(doc.folderId) : [],
}));
const foldersWithPath = folders.map((folder) => ({
...folder,
folderPath: folder.parentId ? buildBreadcrumb(folder.parentId) : [],
// Build breadcrumb paths for documents that are in folders
const folderIds = new Set<string>();
for (const doc of documents) {
if (doc.folderId) folderIds.add(doc.folderId);
}
for (const folder of folders) {
if (folder.parentId) folderIds.add(folder.parentId);
}
const allFoldersInDataroom =
folderIds.size > 0
? await prisma.dataroomFolder.findMany({
where: { dataroomId },
select: { id: true, name: true, path: true, parentId: true },
})
: [];
const folderMap = new Map(
allFoldersInDataroom.map((f) => [f.id, f]),
);
const buildBreadcrumb = (folderId: string): string[] => {
const names: string[] = [];
let currentId: string | null = folderId;
while (currentId) {
const folder = folderMap.get(currentId);
if (!folder) break;
names.unshift(folder.name);
currentId = folder.parentId;
}
return names;
};
const documentsWithPath = documents.map((doc) => ({
...doc,
folderPath: doc.folderId ? buildBreadcrumb(doc.folderId) : [],
}));
const foldersWithPath = folders.map((folder) => ({
...folder,
folderPath: folder.parentId ? buildBreadcrumb(folder.parentId) : [],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/api/teams/`[teamId]/datarooms/[id]/search.ts around lines 124 - 160,
The breadcrumb map is only populated when documents have folderId, so folder
results miss paths; change the folderId collection logic to include folder
matches as well (include IDs from documents and from the returned folders array)
and load allFoldersInDataroom whenever either set is non-empty (use the combined
folderIds length > 0). Also update how you compute folder breadcrumbs for folder
results: call buildBreadcrumb(folder.id) (not folder.parentId) so the folder's
own ancestry is included; references: folderIds, allFoldersInDataroom,
folderMap, buildBreadcrumb, documents, foldersWithPath.

}));

return res.status(200).json({
documents: documentsWithPath,
folders: foldersWithPath,
});
} catch (error) {
console.error("Request error", error);
return res
.status(500)
.json({ error: "Error searching dataroom" });
}
}
Loading
Loading