Compare commits

..
87 Commits
Author SHA1 Message Date
Marc Seitz bfc02040ca fix: limit source files 2026-03-05 21:42:34 +11:00
Marc Seitz 1e65b74b8d Merge branch 'codex/agent' of github.com:mfts/papermark into codex/agent 2026-03-05 17:58:56 +11:00
Marc Seitz a0f91e4b93 fix 2026-03-05 17:58:34 +11:00
Marc SeitzGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
6ead6e6410 Update ee/features/ai/components/chat-message.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-05 17:49:36 +11:00
Marc SeitzGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
0df4ab5a74 Potential fix for code scanning alert no. 92: Incomplete string escaping or encoding
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-05 17:47:52 +11:00
Marc Seitz afa7e0fa7d feat(ee): improve agents 2026-03-05 17:38:18 +11:00
Marc SeitzandGitHub 120a1b88fb Merge pull request #2083 from mfts/cursor/root-domain-redirects-96b8
Root domain redirects
2026-03-05 14:33:58 +11:00
Marc SeitzandGitHub 86b0ee68b9 Merge pull request #2095 from mfts/cursor/upload-limit-warnings-9750
Upload limit warnings
2026-03-02 18:49:50 +11:00
Marc Seitz a44bae5edd fix: fix propagate button 2026-03-02 18:31:27 +11:00
Marc Seitz 6950dec056 chore: prettier 2026-03-02 18:21:53 +11:00
Marc Seitz 6e9d6fe7d4 feat: force https on redirect url 2026-03-02 18:21:42 +11:00
Marc Seitz 5e115f1896 refactor: remove outdated getTeamWithDomain helper 2026-03-02 18:21:31 +11:00
Marc Seitz 4b557e909c Merge branch 'main' into cursor/root-domain-redirects-96b8 2026-03-02 17:56:26 +11:00
Marc Seitz 8c3d677af8 Merge branch 'main' into cursor/upload-limit-warnings-9750 2026-03-02 17:46:24 +11:00
Marc SeitzandGitHub 6687d842db Merge pull request #2096 from mfts/cursor/file-upload-authentication-bypass-dcd1
File upload authentication bypass
2026-03-02 17:43:12 +11:00
Marc SeitzandGitHub 4a969ad9ad Merge pull request #2093 from mfts/feat/dataroom-visitor-notifications
feat: add notification settings for visitors
2026-03-02 17:27:44 +11:00
Cursor AgentandMarc Seitz 0b1acdf136 Cap folder traversal at remainingDocuments to prevent unnecessary backend folder creation
getFilesFromEvent now tracks a collectedFileCount counter and a fileLimit
derived from remainingDocuments. traverseFolder checks the counter before
entering directories (skipping folder creation) and before resolving files.
Once the budget is exhausted the traversal short-circuits, so no extra API
calls are made for folders that would never receive uploads.

A fileLimitTruncatedRef signals to onDrop that files were capped during
traversal so the warning toast fires even though acceptedFiles.length is
already <= remainingDocuments. The else-if branch in onDrop still handles
the file-picker path (no traversal) as a safety net.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 06:23:49 +00:00
Marc Seitz 13a98ca7e5 fix: don't send empty digest 2026-03-02 17:23:23 +11:00
Cursor AgentandMarc Seitz 015c44af80 Harden TUS upload auth, team access, and limits
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 06:11:09 +00:00
Marc SeitzandGitHub 1ebfd522a3 Merge pull request #2094 from mfts/cursor/q-a-content-wrapping-64f3
Q&a content wrapping
2026-03-02 16:59:14 +11:00
Cursor AgentandMarc Seitz 82b47a3e76 fix: increase Q&A message limit to 4000 chars and surface validation errors
The admin was unable to reply to conversations because the 1000 character
limit was too restrictive for dataroom Q&A. This commit:

- Increases the default message character limit from 1000 to 4000
- Surfaces the actual validation error message (e.g. 'Content cannot be
  longer than 4000 characters') through the API instead of returning a
  generic 'Internal server error'
- Parses and displays the API error in the frontend toast notification
- Adds a character counter to the admin reply form with visual feedback
  when approaching/exceeding the limit
- Adds maxLength to viewer-side input fields for client-side enforcement

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 05:08:58 +00:00
Cursor AgentandMarc Seitz 0f59913c14 fix: Q&A content text wrapping so questions and answers are not cut off
- conversation-message.tsx: Change w-max to w-fit on message bubbles so
  text wraps at max-w-[80%] instead of overflowing. Add min-w-0 and
  break-words to the content paragraph for proper word wrapping.

- faq-section.tsx: Add min-w-0 and break-words to FAQ answer text to
  prevent overflow in flex containers. Add break-words to question text
  and min-w-0 to accordion trigger for consistent wrapping behavior.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-03-02 04:59:51 +00:00
Marc Seitz d5c7a6d109 fix 2026-03-02 15:05:35 +11:00
Marc Seitz c394f623eb fix: try and catch around sending notification 2026-03-02 13:58:19 +11:00
Marc Seitz adde340bbd fix: don't ignore other keys in notificationprefs 2026-03-02 13:13:16 +11:00
Marc Seitz 32275c6068 fix: wrap in try catch 2026-03-02 13:13:04 +11:00
Marc Seitz ef2f06f824 fix: throw if no linkurl present 2026-03-02 13:12:54 +11:00
Marc Seitz 8d5568b807 fix: classname typo 2026-03-02 13:12:39 +11:00
Marc Seitz eaabf16b2c fix: type error 2026-03-02 12:40:58 +11:00
Marc Seitz 3c7e4f93d0 feat: add notification settings for visitors 2026-03-02 12:31:44 +11:00
Marc SeitzandGitHub 3f95f1c118 Merge pull request #2092 from mfts/feat/deps
fix: remove warning
2026-03-02 12:20:05 +11:00
Marc Seitz 996a40ceb9 fix: remove warning 2026-03-01 13:33:15 +11:00
Marc SeitzandGitHub 6c1f5e261d Merge pull request #2091 from mfts/chore/robots
chore: update robots
2026-02-28 17:11:50 +11:00
Marc Seitz dc0de8a8dd chore: update robots 2026-02-28 16:58:45 +11:00
Marc Seitz 898313f5a5 fix: remove redirect if it fails 2026-02-28 15:29:32 +11:00
Marc Seitz a279392ba9 chore: make sure keyword matching is case insentive 2026-02-28 15:24:01 +11:00
Marc Seitz 6951b2d92a feat: add zod validation for domain update body 2026-02-28 15:23:35 +11:00
Marc Seitz 76444e2992 Merge branch 'main' into cursor/root-domain-redirects-96b8 2026-02-28 15:20:12 +11:00
Marc SeitzandGitHub 47c256a53c Merge pull request #2004 from mfts/cursor/PM-468-dataroom-upload-visibility-b14f
Dataroom upload visibility
2026-02-28 08:38:33 +11:00
Marc Seitz a0570a18ca fix: colors for uploads 2026-02-28 08:23:19 +11:00
Marc Seitz 58d31905ef feat: successive uploads 2026-02-28 08:23:12 +11:00
Marc Seitz b8c6bb093f Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-27 19:05:29 +11:00
Marc SeitzandGitHub 24b22dc1df Merge pull request #2041 from mfts/deal-flow-infp
Deal flow info
2026-02-27 17:50:49 +11:00
Marc Seitz c9b249c969 fix: add track dismissed state 2026-02-27 17:41:34 +11:00
Marc Seitz f677204bc2 feat: add migration 2026-02-27 17:11:08 +11:00
Marc Seitz 71b243ad9b fix: skip dealsize for pm 2026-02-27 16:50:26 +11:00
Marc Seitz 7216556de2 fix: typo 2026-02-27 16:49:34 +11:00
Marc Seitz 223d81adbf fix: save survey and submission guard 2026-02-27 16:47:56 +11:00
Marc Seitz e19ae608d9 fix: disable skip button when submitting 2026-02-27 16:45:55 +11:00
Marc Seitz be9c7dedae fix: return errorhandler 2026-02-27 16:45:38 +11:00
Marc Seitz 9bdbe333ad fix: use zod validated data instead of body 2026-02-27 16:44:23 +11:00
Marc Seitz f1a686e39d Merge branch 'main' into deal-flow-infp 2026-02-27 16:39:43 +11:00
Marc Seitz d3bd1802cd fix: return early if no document present 2026-02-27 16:35:46 +11:00
Marc Seitz 3ae1dbb678 fix: clear uploads and uploadids 2026-02-27 16:33:43 +11:00
Marc Seitz 58476317a5 refactor: extract duplicate filter logic 2026-02-27 16:33:28 +11:00
Marc Seitz c670f29273 feat: reset rejected file 2026-02-27 16:30:17 +11:00
Marc SeitzandGitHub 996d5f0e21 Merge pull request #2090 from mfts/cursor/user-settings-billing-popup-f92e
User settings billing popup
2026-02-27 16:23:00 +11:00
Cursor AgentandMarc Seitz 0dd914a87d Add pre-upload validation warnings for document and bulk upload limits
- Handle 'too-many-files' rejection in onDropRejected with clear message
  showing the max files per upload limit and suggesting smaller batches
- Improve onDrop document limit check: show usage (X/Y) and upgrade action
  when at limit, and truncate files with warning when partially exceeding
- Add early return in getFilesFromEvent to skip folder creation when at limit
- Improve add-document-modal error messages with usage context and upgrade CTA
- Fix remainingDocuments calculation: use Infinity for unlimited plans

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-26 14:54:47 +00:00
Cursor AgentandMarc Seitz febb9322a7 fix: remove auto-opening upgrade modal from User Settings page
The account/general page had a useEffect that automatically opened
an UpgradePlanModal every time a non-annual-plan user navigated to
User Settings. This was disruptive as users would see a billing popup
about upgrading to data rooms on every visit.

Removed the UpgradePlanModal component, the useEffect trigger, the
plan detection logic, and all related imports from the page.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-26 03:08:16 +00:00
Marc SeitzandGitHub 661014c893 Merge pull request #2088 from mfts/cursor/viewer-link-content-responsiveness-d996
Viewer link content responsiveness
2026-02-25 22:32:54 +11:00
Marc SeitzandCursor 53d328c2d9 fix: resolve three bugs in upload visibility feature
- Fix persisted uploads stuck in 'processing': updatePendingUpload now
  updates both in-flight and persisted state arrays
- Fix duplicate filename collision: key pending upload IDs by batch index
  instead of filename, thread index through onUploadComplete callback
- Fix modal premature close on multi-file upload: track expected/completed
  counts and only fire onUploadSuccess after all files resolve

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 18:30:15 +11:00
Marc SeitzandCursor 5f81333e6e feat: add dataroom upload visibility with optimistic UI and persisted uploads
- Add GET endpoint to fetch viewer's previously uploaded documents
- Return document data from POST upload for optimistic UI rendering
- Add PendingUploadsProvider context with server-side persistence
- Add PendingDocumentCard with Trigger.dev realtime processing status
- Add Documents/My Uploads tab switcher in dataroom viewer
- Redesign upload modal with folder indicator and success state
- Redesign upload component with progress bars and drag-drop zone

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 18:30:08 +11:00
Marc SeitzandGitHub 47922d48d7 Merge pull request #2089 from mfts/cursor/dataroom-document-audit-analytics-7cbc
feat: improve dataroom audit analytics
2026-02-25 18:15:25 +11:00
Marc Seitz 3794b3eeff fix: auth dataroom to team 2026-02-25 18:10:33 +11:00
Marc Seitz 8feb0e2d6e fix 2026-02-25 18:09:22 +11:00
Marc SeitzandCursor 4e0969ab71 fix: increase xs gauge size to fit 3-digit values like 100
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:42 +11:00
Marc SeitzandCursor 3c20959060 fix: pass documentId through chart data and add rate limiting to thumbnail API
The tooltip was using router.query.id (dataroom ID) instead of the
actual document ID, and versionNumber was NaN. Pass documentId through
BarChartComponent data so the tooltip resolves the correct thumbnail.
Add rate limiting (150 req/min per user) to the get-thumbnail endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:36 +11:00
Marc SeitzandCursor 4145145b74 refactor: restructure dataroom audit log layout with proper columns
Move document stats (duration, completion) into dedicated table columns
instead of squeezing them next to the document name. Page-by-page
analytics now opens as a separate expandable row. Replace "See document"
button with arrow link icon. Add table-fixed layout with explicit column
widths to prevent shifting on expand. Conditionally show column headers
only when a row is expanded. Slim down inner row padding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:14:30 +11:00
Marc SeitzandGitHub 9d7b8bf8f2 Merge pull request #2087 from mfts/fix/cjk-slugify
fix: slugify cjk names
2026-02-25 15:43:30 +11:00
Marc Seitz 2425a8933b feat: add transliteration 2026-02-25 15:22:06 +11:00
Marc SeitzandCursor d1a3603837 fix: preserve original CJK filename in Content-Disposition header
Use RFC 5987 filename* parameter to include the UTF-8 encoded original
filename alongside the ASCII-safe slug, so downloads show the proper
CJK name in modern browsers. Also adds missing Content-Disposition to
put-file-server and stream-file-server uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 14:43:02 +11:00
Marc SeitzandCursor 70beab2c5e fix: add CJK-safe slugify with nanoid fallback
slugify strips all CJK characters, producing empty strings for
filenames and folder names that contain only CJK text. Replace all
usages with safeSlugify which falls back to a 12-char lowercase
alphanumeric nanoid when slugify returns an empty result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 14:42:53 +11:00
Cursor AgentandMarc Seitz e5dab063db Fix horizontal viewer refit after viewport resize
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-24 22:45:32 +00:00
Iuliia Shnai c7c1f59649 feat:updates 2026-02-23 19:31:26 +11:00
Iuliia Shnai 7fd0d7bdfe feat:updates 2026-02-23 19:01:12 +11:00
Marc Seitz cf1e783d8f Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-23 13:19:45 +11:00
Cursor AgentandMarc Seitz 5df098f630 fix: clean up unused imports, add stagger delay for per-page chart loading
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 09:20:45 +00:00
Cursor AgentandMarc Seitz 6d7d88431b feat: add document stats (duration, completion, per-page chart) to dataroom visitor audit log
- Create new Tinybird pipe get_dataroom_view_document_stats for bulk document view stats
- Add API endpoint for dataroom view document stats with per-page chart support
- Create SWR hooks with deferred loading (enabled flag)
- Create reusable DocumentViewDuration, DocumentViewCompletion, DocumentPageChart components
- Create DataroomViewStats component combining history with stats
- Update dataroom-visitors-table.tsx and dataroom-viewers.tsx to track expansion state
- Stats only load when visitor row is expanded (deferred loading)
- Per-page charts only load when individual document line is expanded

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 09:14:36 +00:00
Cursor AgentandMarc Seitz ce2b40daad fix: harden root domain redirects - validation, plan gating, cleanup
- Change redirect status from 301 to 302 (non-permanent, user-configurable)
- Remove backfill UPDATE entries from migration SQL
- Remove unnecessary sync-redirects cron endpoint
- Add URL validation: SSRF protection, edge config keyword blocklist,
  protocol check, URL sanitization via shared validateRedirectUrl helper
- Add plan gating: require Business plan or higher for redirect URLs
  (enforced on both API endpoints and UI)
- Clean up Redis redirect URLs on team deletion
- Clear redirect URLs (Postgres + Redis) on subscription cancellation
  and on plan downgrade below Business
- Split redis.ts to avoid Prisma import in edge middleware context
- Gate DomainCard redirect UI based on plan (show upgrade prompt)

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-22 08:24:29 +00:00
Cursor AgentandMarc Seitz 2709049f68 feat: add UI for root domain redirect and Redis sync endpoint
- Update DomainCard to accept and display redirectUrl with inline editor
- Add save/remove functionality for redirect URL with toast notifications
- Pass redirectUrl from domains settings page to DomainCard
- Add /api/cron/domains/sync-redirects endpoint to sync Postgres -> Redis
- Add backfill SQL for existing hardcoded domain redirects

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-20 22:50:09 +00:00
Cursor AgentandMarc Seitz e81d4118aa feat: add root domain redirect support (schema, redis, middleware, API)
- Add redirectUrl field to Domain model in Prisma schema
- Create migration for the new field
- Add Redis helper for domain redirect URL caching
- Update middleware to check Redis for redirect URL instead of hardcoded values
- Update domain API: GET includes redirectUrl, POST accepts redirectUrl, PUT updates redirectUrl
- Sync Redis on domain create, update, and delete

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-20 22:45:48 +00:00
Marc Seitz 5d041f20f8 Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-02-12 16:49:10 +11:00
Iuliia Shnai 8a874e10e0 feat:updates 2026-02-06 16:21:41 +11:00
Iuliia Shnai 7c50bf6242 featL:updates 2026-02-06 14:31:28 +11:00
Iuliia Shnai fb51399469 feat: add two sruvey questions 2026-02-06 14:29:12 +11:00
Marc Seitz 053394901c Merge branch 'main' into cursor/PM-468-dataroom-upload-visibility-b14f 2026-01-28 12:24:04 +11:00
Cursor Agentandmarcftone 5e7ff9fa29 feat: show uploaded documents immediately with processing state
- Add PendingUploadsContext for managing optimistic uploads
- Create PendingDocumentCard component to show uploading/processing documents
- Update upload API to return document data for optimistic display
- Update ViewerUploadComponent to track pending uploads
- Update DataroomViewer to display pending uploads at top of list
- Update DocumentUploadModal with success feedback and auto-close
- Add 'pending' prefix to id-helper for generating pending upload IDs

This ensures external visitors see their uploaded documents immediately
after upload, with a clear 'Processing...' indicator while the document
is being processed on the backend.

Fixes PM-468

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-21 10:33:38 +00:00
133 changed files with 9295 additions and 4671 deletions
@@ -35,8 +35,11 @@ export async function POST(
);
}
const { content, filterDocumentId, filterDataroomDocumentIds } =
validation.data;
const {
content,
filterDocumentId,
filterDataroomDocumentIds,
} = validation.data;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
@@ -133,16 +136,49 @@ export async function POST(
}
// Send message and get streaming response
const result = await sendMessage({
const { result, referencesForStream } = await sendMessage({
chatId,
content,
vectorStoreId: chat.vectorStoreId,
filteredDataroomDocumentIds,
filterDocumentId,
userSelectedDataroomDocumentIds: filterDataroomDocumentIds,
dataroomId: chat.dataroomId || undefined,
linkId: chat.linkId || undefined,
});
return result.toTextStreamResponse();
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
const referencesSection = await Promise.race([
referencesForStream,
new Promise<string>((resolve) =>
setTimeout(() => resolve(""), 5000),
),
]);
if (referencesSection) {
controller.enqueue(encoder.encode(referencesSection));
}
controller.close();
} catch (error) {
console.error("Error streaming AI response:", error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
} catch (error) {
console.error("Error sending message:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
+116 -2
View File
@@ -10,6 +10,107 @@ import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
import { runs } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
/**
* GET /api/links/[id]/upload?dataroomId=xxx
* Returns the viewer's previously uploaded documents for this dataroom.
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const linkId = params.id;
const dataroomId = request.nextUrl.searchParams.get("dataroomId");
if (!linkId || !dataroomId) {
return NextResponse.json(
{ message: "Missing required parameters" },
{ status: 400 },
);
}
// Verify the dataroom session
const dataroomSession = await verifyDataroomSession(
request,
linkId,
dataroomId,
);
if (!dataroomSession || !dataroomSession.viewerId) {
return NextResponse.json(
{ message: "Unauthorized" },
{ status: 401 },
);
}
const { viewerId } = dataroomSession;
// Fetch the viewer's uploads for this dataroom
const uploads = await prisma.documentUpload.findMany({
where: {
viewerId,
dataroomId,
linkId,
},
select: {
id: true,
documentId: true,
dataroomDocumentId: true,
originalFilename: true,
uploadedAt: true,
document: {
select: {
id: true,
name: true,
type: true,
versions: {
where: { isPrimary: true },
select: {
id: true,
hasPages: true,
},
take: 1,
},
},
},
dataroomDocument: {
select: {
folderId: true,
},
},
},
orderBy: { uploadedAt: "desc" },
});
const formattedUploads = uploads.map((upload) => {
const fileType = upload.document?.type ?? "";
const hasPages = upload.document?.versions?.[0]?.hasPages ?? false;
const needsProcessing = ["pdf", "docs", "slides"].includes(fileType);
const isComplete = !needsProcessing || hasPages;
return {
id: upload.id,
documentId: upload.documentId,
dataroomDocumentId: upload.dataroomDocumentId,
documentVersionId: upload.document?.versions?.[0]?.id ?? null,
name: upload.originalFilename ?? upload.document?.name ?? "Unknown",
fileType,
folderId: upload.dataroomDocument?.folderId ?? null,
uploadedAt: upload.uploadedAt,
status: isComplete ? "complete" : "processing",
};
});
return NextResponse.json({ uploads: formattedUploads });
} catch (error) {
console.error("Error fetching viewer uploads:", error);
return NextResponse.json(
{ message: "Error fetching uploads" },
{ status: 500 },
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } },
@@ -167,7 +268,7 @@ export async function POST(
viewId: viewId,
linkId: linkId,
originalFilename: document.name,
fileSize: document.versions[0].fileSize,
fileSize: documentData.fileSize ?? 0,
numPages: document.numPages,
mimeType: document.contentType,
dataroomId: dataroomId,
@@ -226,7 +327,20 @@ export async function POST(
}
}
return NextResponse.json({ success: true });
// Return document data for optimistic UI rendering
return NextResponse.json({
success: true,
document: {
id: document.id,
name: document.name,
dataroomDocumentId: newDataroomDocument.id,
documentVersionId: document.versions[0]?.id,
folderId: dataroomFolderId,
fileType: document.type,
hasPages: (document.numPages ?? 0) > 0,
createdAt: document.createdAt,
},
});
} catch (error) {
console.error("Error uploading document:", error);
return NextResponse.json(
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { receiver } from "@/lib/cron";
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
import { log } from "@/lib/utils";
// Runs daily at 9 AM UTC (0 9 * * *)
export const maxDuration = 300;
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const result = await processDataroomDigest("daily");
return NextResponse.json({ success: true, ...result });
} catch (error) {
await log({
message: `Daily dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { receiver } from "@/lib/cron";
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
import { log } from "@/lib/utils";
// Runs weekly on Monday at 9 AM UTC (0 9 * * 1)
export const maxDuration = 300;
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const result = await processDataroomDigest("weekly");
return NextResponse.json({ success: true, ...result });
} catch (error) {
await log({
message: `Weekly dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
-1
View File
@@ -1,5 +1,4 @@
User-Agent: *
Disallow: /login
Disallow: /register
Disallow: /verify/
Disallow: /auth/
+8 -5
View File
@@ -11,16 +11,19 @@ const CustomTooltip = ({
payload: any;
active: boolean | undefined;
}) => {
const router = useRouter(); // Call useRouter at the top level
const documentId = router.query.id as string;
const router = useRouter();
const routerDocumentId = router.query.id as string;
// Default pageNumber to 0 or a sensible default if payload is not available
const pageNumber =
payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;
// Default versionNumber to 0 or a sensible default if payload is not available
const documentId =
payload && payload.length > 0 && payload[0].payload.documentId
? (payload[0].payload.documentId as string)
: routerDocumentId;
const versionNumber =
payload && payload.length > 0
payload && payload.length > 0 && !isNaN(parseInt(payload[0].payload.versionNumber))
? parseInt(payload[0].payload.versionNumber)
: 1;
const { data, error } = useDocumentThumbnail(
+10 -3
View File
@@ -24,13 +24,18 @@ const renameDummyDurationKey = (data: Data[]): TransformedData[] => {
}, [] as TransformedData[]);
};
const renameSumDurationKey = (data: SumData[], versionNumber?: number) => {
const renameSumDurationKey = (
data: SumData[],
versionNumber?: number,
documentId?: string,
) => {
return data.map((item) => {
return {
...item,
"Time spent per page": item.sum_duration,
sum_duration: undefined,
versionNumber: versionNumber,
versionNumber: versionNumber ?? 1,
documentId: documentId,
};
});
};
@@ -64,16 +69,18 @@ export default function BarChartComponent({
isSum = false,
isDummy = false,
versionNumber,
documentId,
}: {
data: any;
isSum?: boolean;
isDummy?: boolean;
versionNumber?: number;
documentId?: string;
}) {
const [, setValue] = useState<any>(null);
if (isSum) {
const renamedData = renameSumDurationKey(data, versionNumber);
const renamedData = renameSumDurationKey(data, versionNumber, documentId);
return (
<BarChart
+1 -1
View File
@@ -148,7 +148,7 @@ export function AddDataroomModal({
},
{
id: "fund-management",
name: "Fund Management",
name: "Fundraising & Reporting",
icon: BuildingIcon,
},
{
@@ -191,7 +191,7 @@ export default function DataroomDocumentCard({
};
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging) {
if (isDragging || menuOpen) {
e.preventDefault();
e.stopPropagation();
return;
@@ -288,7 +288,7 @@ export default function DataroomDocumentCard({
<DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>
<DropdownMenuTrigger asChild>
<Button
// size="icon"
onClick={(e) => e.stopPropagation()}
variant="outline"
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
>
+37 -4
View File
@@ -85,7 +85,7 @@ export function AddDocumentModal({
}[]
>([]);
const teamInfo = useTeam();
const { canAddDocuments } = useLimits();
const { canAddDocuments, limits } = useLimits();
const { plan, isFree, isTrial, isPaused } = usePlan();
const { dataroom } = useDataroom();
const teamId = teamInfo?.currentTeam?.id as string;
@@ -229,7 +229,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
@@ -415,7 +426,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
@@ -566,7 +588,18 @@ export function AddDocumentModal({
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
toast.error(
limits?.documents
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
: "You have reached the maximum number of documents.",
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
+2 -2
View File
@@ -147,7 +147,7 @@ export default function FolderCard({
};
const handleCardClick = (e: React.MouseEvent) => {
if (isDragging) {
if (isDragging || menuOpen) {
e.preventDefault();
e.stopPropagation();
return;
@@ -258,7 +258,7 @@ export default function FolderCard({
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
// size="icon"
onClick={(e) => e.stopPropagation()}
variant="outline"
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
>
+12 -22
View File
@@ -1,13 +1,9 @@
import { useEffect, useRef, useState, type ElementType } from "react";
import { type ElementType, useEffect, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import { LinkType } from "@prisma/client";
import {
AlertTriangleIcon,
CircleCheckIcon,
InfoIcon,
} from "lucide-react";
import { AlertTriangleIcon, CircleCheckIcon, InfoIcon } from "lucide-react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
@@ -211,25 +207,21 @@ export function AddDomainModal({
if (saveDisabled) {
return toast.error(
statusMessageOverride ??
"Please enter a valid domain before adding.",
statusMessageOverride ?? "Please enter a valid domain before adding.",
);
}
setSubmitting(true);
try {
const response = await fetch(
`/api/teams/${teamId}/domains`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
domain: normalizedDomain,
}),
const response = await fetch(`/api/teams/${teamId}/domains`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({
domain: normalizedDomain,
}),
});
if (!response.ok) {
const { message } = await response.json();
@@ -370,9 +362,7 @@ export function AddDomainModal({
"Enter a valid domain to check availability."
)}
</p>
{StatusIcon && (
<StatusIcon className="h-5 w-5 shrink-0" />
)}
{StatusIcon && <StatusIcon className="h-5 w-5 shrink-0" />}
</div>
</div>
);
+76
View File
@@ -4,6 +4,7 @@ import { useTeam } from "@/context/team-context";
import {
ChevronDownIcon,
CircleCheckIcon,
ExternalLinkIcon,
FlagIcon,
GlobeIcon,
MoreVertical,
@@ -12,6 +13,7 @@ import {
TrashIcon,
} from "lucide-react";
import { motion } from "motion/react";
import { toast } from "sonner";
import { mutate } from "swr";
import { cn } from "@/lib/utils";
@@ -24,6 +26,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { StatusBadge } from "@/components/ui/status-badge";
import { useDeleteDomainModal } from "./delete-domain-modal";
@@ -33,15 +36,21 @@ import { useDomainStatus } from "./use-domain-status";
export default function DomainCard({
domain,
isDefault,
redirectUrl: initialRedirectUrl,
redirectsAllowed,
onDelete,
}: {
domain: string;
isDefault: boolean;
redirectUrl?: string | null;
redirectsAllowed: boolean;
onDelete: (deletedDomain: string) => void;
}) {
const [showDetails, setShowDetails] = useState(false);
const [groupHover, setGroupHover] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [redirectUrl, setRedirectUrl] = useState(initialRedirectUrl || "");
const [savingRedirect, setSavingRedirect] = useState(false);
const domainRef = useRef<HTMLDivElement>(null);
@@ -65,6 +74,34 @@ export default function DomainCard({
onDelete,
});
const handleSaveRedirectUrl = async () => {
setSavingRedirect(true);
try {
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirectUrl: redirectUrl || null }),
});
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || "Failed to save redirect URL");
return;
}
mutate(`/api/teams/${teamId}/domains`);
toast.success(
redirectUrl
? "Root redirect URL saved"
: "Root redirect URL removed",
);
} catch {
toast.error("Failed to save redirect URL");
} finally {
setSavingRedirect(false);
}
};
const handleMakeDefault = async () => {
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
method: "PATCH",
@@ -232,6 +269,45 @@ export default function DomainCard({
) : (
<div className="mt-6 h-6 w-32 animate-pulse rounded-md bg-gray-200 dark:bg-gray-400" />
)}
{/* Root domain redirect */}
<div className="mt-4 rounded-lg border border-gray-200 p-4 dark:border-gray-400">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ExternalLinkIcon className="h-4 w-4" />
Root Domain Redirect
</div>
{redirectsAllowed ? (
<>
<p className="mt-1 text-xs text-muted-foreground">
Redirect visitors who land on{" "}
<span className="font-medium">{domain}</span> to a specific
URL. Leave empty to redirect to papermark.com.
</p>
<div className="mt-3 flex items-center gap-2">
<Input
type="url"
placeholder="https://example.com"
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
className="h-9 flex-1"
/>
<Button
size="sm"
onClick={handleSaveRedirectUrl}
disabled={savingRedirect}
className="h-9 shrink-0"
>
{savingRedirect ? "Saving..." : "Save"}
</Button>
</div>
</>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Root domain redirects require a{" "}
<span className="font-semibold">Business</span> plan or higher.
</p>
)}
</div>
</motion.div>
</div>
<DeleteDomainModal />
@@ -0,0 +1,114 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
type DocumentChange = {
documentName: string;
};
export default function DataroomDigestNotification({
dataroomName = "Example Data Room",
documents = [
{ documentName: "Document A" },
{ documentName: "Document B" },
{ documentName: "Document C" },
],
senderEmail = "example@example.com",
url = "https://app.papermark.com/datarooms/123",
preferencesUrl = "https://app.papermark.com/notification-preferences?token=abc",
frequency = "daily",
}: {
dataroomName: string;
documents: DocumentChange[];
senderEmail: string;
url: string;
preferencesUrl: string;
frequency: "daily" | "weekly";
}) {
const count = documents.length;
const periodLabel = frequency === "daily" ? "today" : "this week";
return (
<Html>
<Head />
<Preview>
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`}
</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-semibold mb-8 mt-4 text-center text-xl">
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">
The following document{count !== 1 ? "s have" : " has"} been added
to <span className="font-semibold">{dataroomName}</span>{" "}
{periodLabel}:
</Text>
<Section className="my-4">
{documents.map((doc, i) => (
<Text
key={i}
className="my-1 text-sm leading-6 text-black"
>
<span className="font-semibold">{doc.documentName}</span>
</Text>
))}
</Section>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={url}
style={{ padding: "12px 20px" }}
>
View the dataroom
</Button>
</Section>
<Text className="text-sm text-black">
or copy and paste this URL into your browser: <br />
{url}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Hr />
<Section className="text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights
reserved.
</Text>
<Text className="text-xs">
You received this {frequency} digest from{" "}
<span className="font-semibold">{senderEmail}</span> because you
viewed the dataroom{" "}
<span className="font-semibold">{dataroomName}</span> on
Papermark. If you have any feedback or questions about this
email, simply reply to it.{" "}
<a
href={preferencesUrl}
className="text-gray-400 underline underline-offset-2"
>
Manage your notification preferences
</a>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+2 -3
View File
@@ -72,13 +72,12 @@ export default function DataroomNotification({
viewed the dataroom{" "}
<span className="font-semibold">{dataroomName}</span> on
Papermark. If you have any feedback or questions about this
email, simply reply to it. To unsubscribe from updates about
this dataroom,{" "}
email, simply reply to it.{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2"
>
click here
Manage your notification preferences
</a>
.
</Text>
+3 -3
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import slugify from "@sindresorhus/slugify";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
@@ -15,6 +14,7 @@ import {
FolderColorId,
FolderIconId,
} from "@/lib/constants/folder-constants";
import { safeSlugify } from "@/lib/utils";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -65,8 +65,8 @@ export function AddFolderModal({
const folderPath =
isDataroom && dataroomId
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + slugify(folderName.trim())}`
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + slugify(folderName.trim())}`;
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + safeSlugify(folderName.trim())}`
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + safeSlugify(folderName.trim())}`;
const addFolderSchema = z.object({
name: z
@@ -282,7 +282,6 @@ export const LinkOptions = ({
{...{ data, setData }}
isAllowed={isTrial || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
linkType={linkType}
/>
{limits?.conversationsInDataroom ? (
@@ -301,18 +300,6 @@ export const LinkOptions = ({
</div>
</CollapsibleSection>
{/* Conversation Section for Document Links */}
{linkType === LinkType.DOCUMENT_LINK ? (
<CollapsibleSection title="Q&A Conversations" defaultOpen={true}>
<ConversationSection
{...{ data, setData }}
isAllowed={isTrial || isPro || isBusiness || isDatarooms || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
linkType={linkType}
/>
</CollapsibleSection>
) : null}
<UpgradePlanModal
clickedPlan={upgradePlan}
open={openUpgradeModal}
+2 -9
View File
@@ -20,7 +20,6 @@ type Props = {
href: string;
segment: string | null;
tag?: string;
count?: number;
disabled?: boolean;
limited?: boolean;
}[];
@@ -38,14 +37,13 @@ export const NavMenu: React.FC<React.PropsWithChildren<Props>> = ({
<div className="flex w-full items-center overflow-x-auto px-4 pl-1">
<ul className="flex flex-row gap-4">
{navigation.map(
({ label, href, segment, tag, count, disabled, limited }) => (
({ label, href, segment, tag, disabled, limited }) => (
<NavItem
key={label}
label={label}
href={href}
segment={segment}
tag={tag}
count={count}
disabled={disabled}
limited={limited}
/>
@@ -63,7 +61,6 @@ const NavItem: React.FC<Props["navigation"][0]> = ({
href,
segment,
tag,
count,
disabled,
limited,
}) => {
@@ -124,11 +121,7 @@ const NavItem: React.FC<Props["navigation"][0]> = ({
)}
>
{label}
{count !== undefined && count > 0 ? (
<div className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground">
{count > 99 ? "99+" : count}
</div>
) : tag ? (
{tag ? (
<div className="text-content-subtle rounded border bg-background px-1 py-0.5 font-mono text-xs">
{tag}
</div>
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { CheckCircleIcon, PencilIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const DEAL_TYPE_OPTIONS = [
{ value: "startup-fundraising", label: "Startup Fundraising" },
{ value: "fund-management", label: "Fundraising & Reporting" },
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
{ value: "financial-operations", label: "Financial Operations" },
{ value: "real-estate", label: "Real Estate" },
{ value: "project-management", label: "Project Management" },
];
const DEAL_SIZE_OPTIONS = [
{ value: "0-500k", label: "$0 - $500K" },
{ value: "500k-5m", label: "$500K - $5M" },
{ value: "5m-10m", label: "$5M - $10M" },
{ value: "10m-100m", label: "$10M - $100M" },
{ value: "100m+", label: "$100M+" },
];
export function SurveySettings() {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [dealType, setDealType] = useState<string | null>(null);
const [dealTypeOther, setDealTypeOther] = useState<string | null>(null);
const [dealSize, setDealSize] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [step, setStep] = useState<1 | 2 | 3>(1);
const [editDealType, setEditDealType] = useState<string | null>(null);
const [editDealTypeOther, setEditDealTypeOther] = useState<string>("");
const [showOtherInput, setShowOtherInput] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const fetchSurvey = async () => {
if (!teamId) return;
try {
const response = await fetch(`/api/teams/${teamId}/survey`);
if (response.ok) {
const data = await response.json();
setDealType(data.dealType);
setDealTypeOther(data.dealTypeOther);
setDealSize(data.dealSize);
// Set initial step based on existing data
if (data.dealType && (data.dealSize || data.dealType === "project-management")) {
setStep(3); // Show completed state
} else if (data.dealType) {
setEditDealType(data.dealType);
setEditDealTypeOther(data.dealTypeOther || "");
setStep(2); // Show deal size question
} else {
setStep(1); // Show deal type question
}
}
} catch (error) {
console.error("Failed to fetch survey data:", error);
} finally {
setIsLoading(false);
}
};
fetchSurvey();
}, [teamId]);
const getDealTypeLabel = (value: string | null, otherText?: string | null) => {
if (!value) return null;
if (value === "other" && otherText) {
return `Other: ${otherText}`;
}
return DEAL_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || value;
};
const getDealSizeLabel = (value: string | null) => {
if (!value) return null;
return DEAL_SIZE_OPTIONS.find((opt) => opt.value === value)?.label || value;
};
const handleEdit = () => {
setEditDealType(dealType);
setEditDealTypeOther(dealTypeOther || "");
setShowOtherInput(false);
setStep(1);
};
const handleDealTypeSelect = async (value: string) => {
setEditDealType(value);
setShowOtherInput(false);
if (value === "project-management") {
// Project management doesn't need deal size - save directly
await handleSave(value, null, null);
} else {
setStep(2);
}
};
const handleOtherSubmit = () => {
if (!editDealTypeOther.trim()) return;
setEditDealType("other");
setShowOtherInput(false);
setStep(2);
};
const handleDealSizeSelect = async (value: string) => {
await handleSave(editDealType, value, editDealTypeOther || null);
};
const handleSave = async (
type?: string | null,
size?: string | null,
otherText?: string | null,
) => {
const finalDealType = type || editDealType;
if (!finalDealType || !teamId) return;
setIsSaving(true);
try {
const response = await fetch(`/api/teams/${teamId}/survey`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
dealType: finalDealType,
dealTypeOther: otherText,
dealSize: size,
}),
});
if (!response.ok) {
throw new Error("Failed to save");
}
setDealType(finalDealType);
setDealTypeOther(otherText || null);
setDealSize(size || null);
setStep(3);
toast.success("Survey answers saved!");
} catch (error) {
console.error("Failed to save survey:", error);
toast.error("Failed to save changes");
} finally {
setIsSaving(false);
}
};
const getDealSizeQuestion = () => {
switch (editDealType) {
case "startup-fundraising":
case "fund-management":
return "How much are you raising?";
case "mergers-acquisitions":
case "real-estate":
case "financial-operations":
return "What's the deal size?";
default:
return "What's the typical deal size?";
}
};
return (
<Card id="team-survey">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Team Survey</CardTitle>
<CardDescription>
This will help us tailor your Papermark experience
</CardDescription>
</div>
{step === 3 && (
<Button variant="outline" size="sm" onClick={handleEdit}>
<PencilIcon className="mr-2 h-4 w-4" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : step === 1 ? (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
</div>
<div className="grid gap-2">
{DEAL_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealTypeSelect(option.value)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
{/* Other - inline input */}
{showOtherInput ? (
<div className="flex gap-2">
<input
type="text"
value={editDealTypeOther}
onChange={(e) => setEditDealTypeOther(e.target.value)}
placeholder="Please specify..."
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && editDealTypeOther.trim()) {
handleOtherSubmit();
}
}}
/>
<button
onClick={handleOtherSubmit}
disabled={!editDealTypeOther.trim() || isSaving}
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
</button>
</div>
) : (
<button
onClick={() => setShowOtherInput(true)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">Other</span>
</button>
)}
</div>
</>
) : step === 2 ? (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold">
{getDealSizeQuestion()}
</h3>
</div>
<div className="grid gap-2">
{DEAL_SIZE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealSizeSelect(option.value)}
disabled={isSaving}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-500" />
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<span className="text-sm text-muted-foreground">Use case</span>
<span className="text-sm font-medium">
{getDealTypeLabel(dealType, dealTypeOther)}
</span>
</div>
{dealSize && (
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<span className="text-sm text-muted-foreground">Deal size</span>
<span className="text-sm font-medium">
{getDealSizeLabel(dealSize)}
</span>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
+333
View File
@@ -0,0 +1,333 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { CheckCircleIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
const DEAL_TYPE_OPTIONS = [
{ value: "startup-fundraising", label: "Startup Fundraising" },
{ value: "fund-management", label: "Fundraising & Reporting" },
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
{ value: "financial-operations", label: "Financial Operations" },
{ value: "real-estate", label: "Real Estate" },
{ value: "project-management", label: "Project Management" },
];
const DEAL_SIZE_OPTIONS = [
{ value: "0-500k", label: "$0 - $500K" },
{ value: "500k-5m", label: "$500K - $5M" },
{ value: "5m-10m", label: "$5M - $10M" },
{ value: "10m-100m", label: "$10M - $100M" },
{ value: "100m+", label: "$100M+" },
];
export function DealflowPopup() {
const router = useRouter();
const teamInfo = useTeam();
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState(1);
const [dealType, setDealType] = useState<string | null>(null);
const [dealTypeOther, setDealTypeOther] = useState<string>("");
const [showOtherInput, setShowOtherInput] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const teamId = teamInfo?.currentTeam?.id;
const storageKey = `dealflow-survey-dismissed-${teamId}`;
// Check if we're on onboarding/welcome pages
const isOnboarding = router.pathname.startsWith("/welcome");
useEffect(() => {
if (isOnboarding) return;
if (!teamId) return;
const dismissed = localStorage.getItem(storageKey);
if (dismissed) return;
let timeoutId: NodeJS.Timeout;
const checkSurvey = async () => {
try {
const response = await fetch(`/api/teams/${teamId}/survey`);
if (response.ok) {
const data = await response.json();
if (data.dismissed) {
localStorage.setItem(storageKey, "true");
return;
}
if (!data.dealType) {
timeoutId = setTimeout(() => {
setStep(1);
setIsOpen(true);
}, 2000);
} else if (!data.dealSize && data.dealType !== "project-management") {
timeoutId = setTimeout(() => {
setDealType(data.dealType);
setDealTypeOther(data.dealTypeOther || "");
setStep(2);
setIsOpen(true);
}, 2000);
}
}
} catch (error) {
console.error("Failed to check survey status:", error);
}
};
checkSurvey();
return () => clearTimeout(timeoutId);
}, [teamId, storageKey, isOnboarding]);
const handleDealTypeSelect = (value: string) => {
setDealType(value);
setShowOtherInput(false);
if (value === "project-management") {
// Project management doesn't need a second step - go straight to thank you
handleSubmit(value, null, null);
} else {
setStep(2);
}
};
const handleOtherSubmit = () => {
if (!dealTypeOther.trim()) return;
setDealType("other");
setShowOtherInput(false);
// After entering "Other" text, go to deal size question
setStep(2);
};
const handleDealSizeSelect = async (value: string) => {
await handleSubmit(dealType, value, dealTypeOther || null);
};
const handleSubmit = async (
type?: string | null,
size?: string | null,
otherText?: string | null,
) => {
const finalDealType = type || dealType;
if (!finalDealType) return;
setIsSubmitting(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/survey`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
dealType: finalDealType,
dealTypeOther:
otherText !== undefined
? otherText
: dealTypeOther || null,
dealSize: size ?? null,
}),
},
);
if (!response.ok) {
throw new Error("Failed to save response");
}
localStorage.setItem(storageKey, "true");
setStep(3); // Go to thank you screen
} catch (error) {
console.error("Failed to save survey response:", error);
toast.error("Failed to save your response");
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setIsOpen(false);
};
const handleDismiss = async () => {
localStorage.setItem(storageKey, "true");
setIsOpen(false);
toast("Survey dismissed", {
description: "You can complete it anytime from Settings → General.",
action: {
label: "Go to Settings",
onClick: () => router.push("/settings/general#team-survey"),
},
});
if (teamId) {
try {
await fetch(`/api/teams/${teamId}/survey`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dismissed: true,
dismissedAt: new Date().toISOString(),
}),
});
} catch (error) {
console.error("Failed to save survey dismissal:", error);
}
}
};
const getDealSizeQuestion = () => {
switch (dealType) {
case "startup-fundraising":
case "fund-management":
return "How much are you raising?";
case "mergers-acquisitions":
case "real-estate":
case "financial-operations":
return "What's the deal size?";
default:
return "What's the typical deal size?";
}
};
if (!isOpen) return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-full max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="rounded-lg border-2 border-black bg-background p-4 shadow-lg">
{step === 1 ? (
<>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
</div>
<button
onClick={handleDismiss}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="grid gap-2">
{DEAL_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealTypeSelect(option.value)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
{/* Other - inline input */}
{showOtherInput ? (
<div className="flex gap-2">
<input
type="text"
value={dealTypeOther}
onChange={(e) => setDealTypeOther(e.target.value)}
placeholder="Please specify..."
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && dealTypeOther.trim()) {
handleOtherSubmit();
}
}}
/>
<button
onClick={handleOtherSubmit}
disabled={!dealTypeOther.trim() || isSubmitting}
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
</button>
</div>
) : (
<button
onClick={() => setShowOtherInput(true)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">Other</span>
</button>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">
This will help us tailor your Papermark experience
</p>
</>
) : step === 2 ? (
<>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">
{getDealSizeQuestion()}
</h3>
</div>
<button
onClick={handleDismiss}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="grid gap-2">
{DEAL_SIZE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealSizeSelect(option.value)}
disabled={isSubmitting}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
>
<span className="font-medium">{option.label}</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
This will help us tailor your Papermark experience
</p>
</>
) : (
<>
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-500" />
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
</div>
<button
onClick={handleClose}
className="rounded-full p-1 hover:bg-muted"
>
<XIcon className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<p className="mb-4 text-sm text-muted-foreground">
You can find and update your responses in settings.
</p>
<button
onClick={() => {
router.push("/settings/general#team-survey");
handleClose();
}}
className="w-full rounded-lg border border-black bg-white px-3 py-2 text-sm font-medium text-black transition-colors hover:bg-gray-50"
>
Go to Team Survey
</button>
</>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export const Gauge = ({
xs: {
width: "24",
height: "24",
textSize: "text-xs",
textSize: "text-[10px]",
},
small: {
width: "36",
+98 -6
View File
@@ -100,9 +100,10 @@ export default function UploadZone({
const teamInfo = useTeam();
const { data: session } = useSession();
const { limits, canAddDocuments, isPaused } = useLimits();
const remainingDocuments = limits?.documents
? limits?.documents - limits?.usage?.documents
: 0;
const hasDocumentLimit = limits?.documents != null && limits.documents > 0;
const remainingDocuments = hasDocumentLimit
? limits.documents - (limits?.usage?.documents ?? 0)
: Infinity;
// Fetch team settings with proper revalidation - ensures settings stay fresh across tabs
const { settings: teamSettings } = useTeamSettings(teamInfo?.currentTeam?.id);
@@ -113,6 +114,7 @@ export default function UploadZone({
// Using promise-lock pattern to prevent race conditions during concurrent folder creation
const dataroomFolderPathRef = useRef<string | null>(null);
const dataroomFolderCreationPromiseRef = useRef<Promise<string> | null>(null);
const fileLimitTruncatedRef = useRef(false);
// Reset the cached dataroom folder path when the replication setting changes
// This ensures we don't use stale cached paths if the setting is toggled
@@ -222,6 +224,25 @@ export default function UploadZone({
const onDropRejected = useCallback(
(rejectedFiles: FileRejection[]) => {
const hasTooManyFiles = rejectedFiles.some(({ errors }) =>
errors.some(({ code }) => code === "too-many-files"),
);
if (hasTooManyFiles) {
const maxFiles = fileSizeLimits.maxFiles ?? 150;
toast.error(
`You're trying to upload ${rejectedFiles.length} files, but you can only upload up to ${maxFiles} files at once. Please upload in smaller batches.`,
{ duration: 8000 },
);
onUploadRejected([
{
fileName: `${rejectedFiles.length} files selected`,
message: `Maximum ${maxFiles} files per upload`,
},
]);
return;
}
const rejected = rejectedFiles.map(({ file, errors }) => {
let message = "";
if (errors.find(({ code }) => code === "file-too-large")) {
@@ -256,13 +277,55 @@ export default function UploadZone({
return;
}
if (!canAddDocuments && acceptedFiles.length > remainingDocuments) {
toast.error("You have reached the maximum number of documents.");
if (hasDocumentLimit && remainingDocuments <= 0) {
toast.error(
`You've reached your plan's document limit (${limits?.usage?.documents}/${limits?.documents} documents). Upgrade your plan to upload more.`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 8000,
},
);
return;
}
let filesToUpload = acceptedFiles;
if (fileLimitTruncatedRef.current) {
// Folder traversal was already capped at remainingDocuments
// no extra folders were created, just show the warning.
fileLimitTruncatedRef.current = false;
toast.warning(
`Your upload was limited to ${acceptedFiles.length} file${acceptedFiles.length === 1 ? "" : "s"} because your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used).`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 10000,
},
);
} else if (hasDocumentLimit && acceptedFiles.length > remainingDocuments) {
// Safety net for the file-picker path (no folder traversal) or
// race conditions where the cap was slightly exceeded.
const skippedCount = acceptedFiles.length - remainingDocuments;
toast.warning(
`You're trying to upload ${acceptedFiles.length} files, but your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used). ${skippedCount} file${skippedCount === 1 ? "" : "s"} will be skipped.`,
{
action: {
label: "Upgrade",
onClick: () => router.push("/settings/billing"),
},
duration: 10000,
},
);
filesToUpload = acceptedFiles.slice(0, remainingDocuments);
}
// Validate files and separate into valid and invalid
const validatedFiles = acceptedFiles.reduce<{
const validatedFiles = filesToUpload.reduce<{
valid: FileWithPaths[];
invalid: { fileName: string; message: string }[];
}>(
@@ -530,6 +593,8 @@ export default function UploadZone({
isFree,
isTrial,
isPaused,
hasDocumentLimit,
remainingDocuments,
],
);
@@ -540,6 +605,18 @@ export default function UploadZone({
return [];
}
fileLimitTruncatedRef.current = false;
const fileLimit =
hasDocumentLimit && isFinite(remainingDocuments)
? Math.max(0, remainingDocuments)
: Infinity;
let collectedFileCount = 0;
// Early check: skip folder traversal (and folder creation) if document limit is already reached
if (fileLimit <= 0) {
return [];
}
let filesToBePassedToOnDrop: FileWithPaths[] = [];
/** *********** START OF `traverseFolder` *********** */
@@ -561,6 +638,10 @@ export default function UploadZone({
return files;
}
if (collectedFileCount >= fileLimit) {
return files;
}
if (entry.isDirectory) {
/**
* Let's create the folder.
@@ -706,6 +787,10 @@ export default function UploadZone({
return files;
}
if (collectedFileCount >= fileLimit) {
return files;
}
let file = await new Promise<FileWithPaths>((resolve) =>
(entry as FileSystemFileEntry).file(resolve),
);
@@ -760,6 +845,7 @@ export default function UploadZone({
file.dataroomUploadPath = dataroomParentPath;
files.push(file);
collectedFileCount++;
}
return files;
@@ -805,6 +891,10 @@ export default function UploadZone({
}
}
if (isFinite(fileLimit) && collectedFileCount >= fileLimit) {
fileLimitTruncatedRef.current = true;
}
return filesToBePassedToOnDrop;
},
[
@@ -817,6 +907,8 @@ export default function UploadZone({
setRejectedFiles,
acceptableDropZoneFileTypes,
getOrCreateDataroomFolder,
hasDocumentLimit,
remainingDocuments,
],
);
+31 -28
View File
@@ -6,6 +6,7 @@ import { DataroomBrand } from "@prisma/client";
import Cookies from "js-cookie";
import { toast } from "sonner";
import { PendingUploadsProvider } from "@/context/pending-uploads-context";
import { useAnalytics } from "@/lib/analytics";
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
@@ -285,34 +286,36 @@ export default function DataroomView({
if (submitted) {
return (
<div
className="min-h-screen bg-white"
style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}
>
<DataroomViewer
accessControls={link.accessControls || group?.accessControls || []}
brand={brand!}
viewId={viewData.viewId}
isPreview={viewData.isPreview}
linkId={link.id}
dataroom={dataroom}
allowDownload={link.allowDownload!}
enableIndexFile={link.enableIndexFile}
folderId={folderId}
setFolderId={setFolderId}
viewerId={viewData.viewerId}
viewData={viewData}
isEmbedded={isEmbedded}
dataroomIndexEnabled={dataroomIndexEnabled}
viewerEmail={
viewData.viewerEmail ??
data.email ??
verifiedEmail ??
userEmail ??
undefined
}
/>
</div>
<PendingUploadsProvider linkId={link.id} dataroomId={dataroom?.id}>
<div
className="min-h-screen bg-white"
style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}
>
<DataroomViewer
accessControls={link.accessControls || group?.accessControls || []}
brand={brand!}
viewId={viewData.viewId}
isPreview={viewData.isPreview}
linkId={link.id}
dataroom={dataroom}
allowDownload={link.allowDownload!}
enableIndexFile={link.enableIndexFile}
folderId={folderId}
setFolderId={setFolderId}
viewerId={viewData.viewerId}
viewData={viewData}
isEmbedded={isEmbedded}
dataroomIndexEnabled={dataroomIndexEnabled}
viewerEmail={
viewData.viewerEmail ??
data.email ??
verifiedEmail ??
userEmail ??
undefined
}
/>
</div>
</PendingUploadsProvider>
);
}
@@ -1,13 +1,16 @@
// components/view/dataroom/document-upload-button.tsx
import { useState } from "react";
import { PlusIcon } from "lucide-react";
import {
CheckCircle2,
FolderIcon,
PlusIcon,
UploadIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -18,13 +21,33 @@ export function DocumentUploadModal({
dataroomId,
viewerId,
folderId,
folderName,
}: {
linkId: string;
dataroomId: string;
viewerId: string;
folderId?: string;
/** Display name of the current folder (undefined = root) */
folderName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const handleUploadSuccess = () => {
setUploadSuccess(true);
// Auto-close the dialog after a short delay to show success message
setTimeout(() => {
setIsOpen(false);
setUploadSuccess(false);
}, 1500);
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setUploadSuccess(false);
}
};
return (
<>
@@ -39,25 +62,54 @@ export function DocumentUploadModal({
<span>Add Document</span>
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Document to Dataroom</DialogTitle>
<DialogDescription>
The data room manager will receive a notification when the
document is uploaded and approve the document. Only then, it will
be visible to all visitors.
</DialogDescription>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl">
<DialogHeader className="border-b border-gray-100 bg-gray-50 px-6 py-5 dark:border-gray-800 dark:bg-gray-900">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<UploadIcon className="h-5 w-5 text-muted-foreground" />
Upload Document to Dataroom
</DialogTitle>
<p className="mt-1 text-sm text-muted-foreground">
Your document will appear immediately in the dataroom and will be
processed in the background.
</p>
{folderName && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<FolderIcon className="h-3.5 w-3.5" />
<span>
Uploading to:{" "}
<span className="font-medium text-foreground">
{folderName}
</span>
</span>
</div>
)}
</DialogHeader>
<ViewerUploadComponent
viewerData={{
id: viewerId,
linkId,
dataroomId,
}}
teamId="visitor-upload"
folderId={folderId}
/>
<div className="px-6 py-5">
{uploadSuccess ? (
<div className="flex flex-col items-center justify-center py-8">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<p className="mt-3 text-sm font-medium text-foreground">
Document uploaded successfully!
</p>
<p className="mt-1 text-xs text-muted-foreground">
Your document is now visible in the dataroom.
</p>
</div>
) : (
<ViewerUploadComponent
viewerData={{
id: viewerId,
linkId,
dataroomId,
}}
teamId="visitor-upload"
folderId={folderId}
onUploadSuccess={handleUploadSuccess}
/>
)}
</div>
</DialogContent>
</Dialog>
</>
@@ -0,0 +1,326 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
import type { CSSProperties } from "react";
import {
AlertCircle,
CheckCircle2,
FileIcon,
FolderIcon,
Loader2,
Upload,
} from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import useSWRImmutable from "swr/immutable";
import {
PendingUploadDocument,
usePendingUploads,
} from "@/context/pending-uploads-context";
import { cn, fetcher } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";
import { useDocumentProgressStatus } from "@/lib/utils/use-progress-status";
import { Progress } from "@/components/ui/progress";
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
type FolderInfo = {
id: string;
name: string;
parentId: string | null;
};
type PendingDocumentCardProps = {
pendingUpload: PendingUploadDocument;
/** Flat list of all folders for resolving folder paths */
folders?: FolderInfo[];
/** Link ID for building document URLs */
linkId?: string;
/** Callback to navigate to a folder */
onNavigateToFolder?: (folderId: string | null) => void;
/** Whether to show the folder path (used in My Uploads tab) */
showFolderPath?: boolean;
};
/** Build a breadcrumb path like "Home > Company Info > Financials > Q4" */
function getFolderPath(
folderId: string | null,
folders: FolderInfo[],
): string {
if (!folderId) return "Home";
const parts: string[] = ["Home"];
const folderParts: string[] = [];
let current = folders.find((f) => f.id === folderId);
while (current) {
folderParts.unshift(current.name);
current = current.parentId
? folders.find((f) => f.id === current!.parentId)
: undefined;
}
return [...parts, ...folderParts].join(" > ");
}
export default function PendingDocumentCard({
pendingUpload,
folders = [],
linkId,
onNavigateToFolder,
showFolderPath = false,
}: PendingDocumentCardProps) {
const { theme, systemTheme } = useTheme();
const router = useRouter();
const { updatePendingUpload } = usePendingUploads();
const { palette } = useViewerSurfaceTheme();
const isLight =
theme === "light" || (theme === "system" && systemTheme === "light");
const { previewToken, domain, slug } = router.query as {
previewToken?: string;
domain?: string;
slug?: string;
};
// Fetch trigger public access token for processing documents
const needsTriggerTracking =
pendingUpload.status === "processing" && pendingUpload.documentVersionId;
const { data: tokenData } = useSWRImmutable<{ publicAccessToken: string }>(
needsTriggerTracking
? `/api/progress-token?documentVersionId=${pendingUpload.documentVersionId}`
: null,
fetcher,
);
// Subscribe to Trigger.dev realtime for processing status
const { status: triggerStatus } = useDocumentProgressStatus(
pendingUpload.documentVersionId ?? "",
tokenData?.publicAccessToken,
);
// When trigger reports COMPLETED, update the upload status
useEffect(() => {
if (
needsTriggerTracking &&
triggerStatus.state === "COMPLETED" &&
pendingUpload.status === "processing"
) {
updatePendingUpload(pendingUpload.id, { status: "complete" });
}
}, [
triggerStatus.state,
needsTriggerTracking,
pendingUpload.status,
pendingUpload.id,
updatePendingUpload,
]);
const isError = pendingUpload.status === "error";
const isUploading = pendingUpload.status === "uploading";
const isProcessing = pendingUpload.status === "processing";
const isComplete = pendingUpload.status === "complete";
const isClickable = isComplete && pendingUpload.dataroomDocumentId && linkId;
const getStatusText = () => {
switch (pendingUpload.status) {
case "uploading":
return `Uploading... ${pendingUpload.progress}%`;
case "processing":
// Use trigger realtime text if available
if (triggerStatus.state === "EXECUTING" && triggerStatus.text) {
return triggerStatus.text;
}
if (triggerStatus.state === "QUEUED") {
return "Queued for processing...";
}
return "Processing document...";
case "error":
return pendingUpload.errorMessage || "Upload failed";
case "complete":
return "Ready";
default:
return "Pending";
}
};
const getStatusIcon = () => {
switch (pendingUpload.status) {
case "uploading":
return <Upload className="h-4 w-4 animate-pulse text-blue-500" />;
case "processing":
return <Loader2 className="h-4 w-4 animate-spin text-orange-500" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-500" />;
case "complete":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
default:
return null;
}
};
// Real processing progress from trigger (0-100)
const processingProgress =
isProcessing && triggerStatus.state === "EXECUTING"
? triggerStatus.progress
: undefined;
const handleDocumentClick = (e: React.MouseEvent) => {
if (!isClickable) {
if (isProcessing) {
e.preventDefault();
toast.error(
"Document is still processing. Please wait a moment and try again.",
);
}
return;
}
e.preventDefault();
if (domain && slug) {
window.open(
`/${slug}/d/${pendingUpload.dataroomDocumentId}`,
"_blank",
);
} else if (linkId) {
window.open(
`/view/${linkId}/d/${pendingUpload.dataroomDocumentId}${
previewToken ? `?previewToken=${previewToken}&preview=1` : ""
}`,
"_blank",
);
}
};
const handleFolderClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onNavigateToFolder) {
onNavigateToFolder(pendingUpload.folderId);
}
};
const folderPath =
showFolderPath && folders.length > 0
? getFolderPath(pendingUpload.folderId, folders)
: null;
return (
<div
className={cn(
"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4",
"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]",
"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]",
isError && "border-red-400/40",
(isUploading || isProcessing) && "opacity-80",
)}
style={
{
"--viewer-panel-bg": palette.panelBgColor,
"--viewer-panel-bg-hover": palette.panelHoverBgColor,
"--viewer-panel-border": palette.panelBorderColor,
"--viewer-panel-border-hover": palette.panelBorderHoverColor,
"--viewer-text": palette.textColor,
"--viewer-muted-text": palette.mutedTextColor,
"--viewer-subtle-text": palette.subtleTextColor,
"--viewer-control-bg": palette.controlBgColor,
"--viewer-control-border": palette.controlBorderColor,
} as CSSProperties
}
>
{/* Clickable overlay for opening documents */}
{isClickable && (
<button
onClick={handleDocumentClick}
className="absolute inset-0 z-0 cursor-pointer"
aria-hidden="true"
/>
)}
{/* pointer-events-none so clicks fall through to the button overlay above */}
<div
className={cn(
"flex min-w-0 shrink items-center space-x-2 sm:space-x-4",
isClickable && "pointer-events-none",
)}
>
<div className="mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1">
{pendingUpload.fileType ? (
fileIcon({
fileType: pendingUpload.fileType,
className: "h-8 w-8 opacity-60",
isLight,
})
) : (
<FileIcon className="h-8 w-8 opacity-60" style={{ color: palette.mutedTextColor }} />
)}
</div>
<div className="min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<h2 className="min-w-0 max-w-[300px] truncate text-sm font-semibold leading-6 text-[var(--viewer-text)] sm:max-w-lg">
{pendingUpload.name}
</h2>
{getStatusIcon()}
</div>
<div className="mt-1 flex items-center gap-2">
<p
className={cn(
"text-xs leading-5",
isError
? "text-red-500"
: isComplete
? "text-green-500"
: "text-[var(--viewer-muted-text)]",
)}
>
{getStatusText()}
</p>
{folderPath && isComplete && (
<>
<span className="text-xs text-[var(--viewer-subtle-text)]">
·
</span>
<button
onClick={handleFolderClick}
className="pointer-events-auto z-10 flex items-center gap-1 text-xs text-[var(--viewer-muted-text)] transition-colors hover:text-[var(--viewer-text)]"
>
<FolderIcon className="h-3 w-3" />
<span className="max-w-[200px] truncate sm:max-w-[300px]">
{folderPath}
</span>
</button>
</>
)}
{folderPath && !isComplete && (
<>
<span className="text-xs text-[var(--viewer-subtle-text)]">
·
</span>
<span className="flex items-center gap-1 text-xs text-[var(--viewer-muted-text)]">
<FolderIcon className="h-3 w-3" />
<span className="max-w-[200px] truncate sm:max-w-[300px]">
{folderPath}
</span>
</span>
</>
)}
</div>
{(isUploading || isProcessing) && (
<Progress
value={
isProcessing
? (processingProgress ?? 0)
: pendingUpload.progress
}
className={cn(
"mt-1.5 h-1 w-full max-w-[200px]",
isProcessing && !processingProgress && "animate-pulse",
)}
/>
)}
</div>
</div>
</div>
);
}
-2
View File
@@ -159,7 +159,6 @@ export default function DocumentView({
} else {
const {
viewId,
viewerId,
file,
pages,
sheetData,
@@ -199,7 +198,6 @@ export default function DocumentView({
setViewData({
viewId,
viewerId,
file,
pages,
sheetData,
+6 -6
View File
@@ -214,6 +214,7 @@ export default function Nav({
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isDataroom &&
conversationsEnabled &&
!showConversations // if conversations are already open, don't toggle them
) {
@@ -229,7 +230,7 @@ export default function Nav({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [conversationsEnabled, showConversations]);
}, [isDataroom, conversationsEnabled, showConversations]);
return (
<nav
@@ -317,14 +318,13 @@ export default function Nav({
</Tooltip>
</TooltipProvider>
)}
{/* Conversation toggle button for dataroom and document links */}
{conversationsEnabled && (
{/* Conversation toggle button for dataroom documents */}
{isDataroom && conversationsEnabled && (
<Button
onClick={() => setShowConversations(!showConversations)}
className="bg-gray-900 text-white hover:bg-gray-900/80"
>
<MessageCircle className="mr-2 h-4 w-4" />
Q&A
View FAQ
</Button>
)}
{/* Annotations toggle button */}
@@ -467,7 +467,7 @@ export default function Nav({
</div>
</div>
</div>
{conversationsEnabled && showConversations ? (
{isDataroom && conversationsEnabled && showConversations ? (
<ConversationSidebar
dataroomId={dataroomId}
documentId={documentId}
+158 -16
View File
@@ -1,6 +1,6 @@
import { useRouter } from "next/router";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import React from "react";
import { ViewerChatPanel } from "@/ee/features/ai/components/viewer-chat-panel";
@@ -16,8 +16,9 @@ import {
ViewerGroupAccessControls,
} from "@prisma/client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { PanelLeftIcon, XIcon } from "lucide-react";
import { PanelLeftIcon, UploadIcon, XIcon } from "lucide-react";
import { usePendingUploads } from "@/context/pending-uploads-context";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
@@ -53,6 +54,7 @@ import {
IntroductionProvider,
} from "../dataroom/introduction-modal";
import DataroomNav from "../dataroom/nav-dataroom";
import PendingDocumentCard from "../dataroom/pending-document-card";
import {
ViewerSurfaceThemeProvider,
createViewerSurfaceTheme,
@@ -182,6 +184,21 @@ export default function DataroomViewer({
const router = useRouter();
const searchQuery = (router.query.search as string)?.toLowerCase() || "";
// Tab state: "documents" (normal view) or "my-uploads" (visitor's uploads)
const [activeTab, setActiveTab] = useState<"documents" | "my-uploads">(
"documents",
);
// Get pending uploads (in-flight + persisted from server)
const {
getPendingUploadsForFolder,
getAllUploads,
hasUploads,
updatePendingUpload,
} = usePendingUploads();
const pendingUploadsForFolder = getPendingUploadsForFolder(folderId);
const allUploads = getAllUploads();
const breadcrumbFolders = useMemo(
() => getParentFolders(folderId, folders),
[folderId, folders],
@@ -349,6 +366,40 @@ export default function DataroomViewer({
searchQuery,
]);
const filteredPendingUploads = useMemo(
() =>
pendingUploadsForFolder.filter((u) => {
if (!u.documentId) return true;
return !mixedItems.some(
(item) => "versions" in item && item.id === u.documentId,
);
}),
[pendingUploadsForFolder, mixedItems],
);
// Fallback reconciliation: if the document is already visible and ready,
// mark its pending upload as complete even if realtime status was missed.
useEffect(() => {
allUploads.forEach((upload) => {
if (upload.status !== "processing" || !upload.documentId) return;
const matchingDocument = documents.find((doc) => doc.id === upload.documentId);
if (!matchingDocument) return;
const primaryVersion = matchingDocument.versions[0];
if (!primaryVersion) return;
const needsProcessing = ["pdf", "docs", "slides"].includes(
primaryVersion.type,
);
const isReady = !needsProcessing || primaryVersion.hasPages;
if (isReady) {
updatePendingUpload(upload.id, { status: "complete" });
}
});
}, [allUploads, documents, updatePendingUpload]);
const renderItem = (item: FolderOrDocument) => {
if ("versions" in item) {
const isProcessing =
@@ -597,14 +648,60 @@ export default function DataroomViewer({
dataroomId={dataroom?.id}
viewerId={viewerId}
folderId={folderId ?? undefined}
folderName={
folderId
? folders.find((f) => f.id === folderId)?.name
: undefined
}
/>
)}
</div>
</div>
</div>
{/* Tabs: Documents / My Uploads */}
{viewData?.enableVisitorUpload && hasUploads && (
<div
className="mt-4 flex items-center gap-1 border-b"
style={{ borderColor: viewerSurfaceTheme.palette.panelBorderColor }}
>
<button
onClick={() => setActiveTab("documents")}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors",
activeTab === "documents"
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
)}
>
Documents
</button>
<button
onClick={() => setActiveTab("my-uploads")}
className={cn(
"-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
activeTab === "my-uploads"
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
)}
>
<UploadIcon className="h-3.5 w-3.5" />
My Uploads
<span
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium"
style={{
backgroundColor: viewerSurfaceTheme.palette.controlBgColor,
color: viewerSurfaceTheme.palette.mutedTextColor,
}}
>
{allUploads.length}
</span>
</button>
</div>
)}
{/* Search results banner */}
{searchQuery && (
{searchQuery && activeTab === "documents" && (
<div className="mt-4 rounded-md border border-[var(--viewer-panel-border)] bg-[var(--viewer-control-bg)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-[var(--viewer-muted-text)]">
@@ -618,19 +715,64 @@ export default function DataroomViewer({
</div>
)}
<ul role="list" className="-mx-4 space-y-4 overflow-auto p-4">
{mixedItems.length === 0 ? (
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
{searchQuery
? "No documents match your search."
: "No items available."}
</li>
) : (
mixedItems.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))
)}
</ul>
{activeTab === "my-uploads" ? (
/* My Uploads tab - show all uploads across all folders */
<ul
role="list"
className="-mx-4 space-y-4 overflow-auto p-4"
>
{allUploads.length === 0 ? (
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
No uploads yet. Upload documents using the &quot;Add
Document&quot; button.
</li>
) : (
allUploads.map((pendingUpload) => (
<li key={pendingUpload.id}>
<PendingDocumentCard
pendingUpload={pendingUpload}
folders={folders}
linkId={linkId}
showFolderPath
onNavigateToFolder={(id) => {
setFolderId(id);
setActiveTab("documents");
}}
/>
</li>
))
)}
</ul>
) : (
/* Documents tab - normal folder view */
<ul
role="list"
className="-mx-4 space-y-4 overflow-auto p-4"
>
{!searchQuery &&
filteredPendingUploads.map((pendingUpload) => (
<li key={pendingUpload.id}>
<PendingDocumentCard
pendingUpload={pendingUpload}
linkId={linkId}
/>
</li>
))}
{mixedItems.length === 0 &&
filteredPendingUploads.length === 0 ? (
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
{searchQuery
? "No documents match your search."
: "No items available."}
</li>
) : (
mixedItems.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))
)}
</ul>
)}
</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
@@ -633,8 +633,16 @@ export default function PagesHorizontalViewer({
<div
className="mx-auto"
style={{
width: scaledWidthPx ? `${scaledWidthPx}px` : "100%",
height: scaledHeightPx ? `${scaledHeightPx}px` : "auto",
// Keep default zoom responsive to viewport changes.
// Only lock dimensions when zoomed in to preserve a stable scroll area.
width:
scale > 1 && scaledWidthPx
? `${scaledWidthPx}px`
: "100%",
height:
scale > 1 && scaledHeightPx
? `${scaledHeightPx}px`
: "auto",
}}
>
{/* Content is scaled; origin set to top-left so it grows into the sizer */}
+190 -63
View File
@@ -1,43 +1,118 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { FileUp } from "lucide-react";
import { toast } from "sonner";
import { usePendingUploads } from "@/context/pending-uploads-context";
import { DocumentData } from "@/lib/documents/create-document";
import { newId } from "@/lib/id-helper";
import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress";
import ViewerUploadZone from "@/components/viewer-upload-zone";
export function ViewerUploadComponent({
viewerData,
teamId,
folderId,
onUploadSuccess,
}: {
viewerData: { id: string; linkId: string; dataroomId?: string };
teamId: string;
folderId?: string;
onUploadSuccess?: () => void;
}) {
const [uploads, setUploads] = useState<
{ fileName: string; progress: number }[]
{ uploadId: string; fileName: string; progress: number }[]
>([]);
const [rejectedFiles, setRejectedFiles] = useState<
{ fileName: string; message: string }[]
>([]);
const handleUploadStart = (
uploads: { fileName: string; progress: number }[],
) => {
setUploads(uploads);
const { addPendingUpload, updatePendingUpload } = usePendingUploads();
// Map each active upload item to its pending upload record
const pendingUploadIds = useRef<Map<string, string>>(new Map());
const activeUploadIds = useRef<Set<string>>(new Set());
const failedCountRef = useRef(0);
const finalizeSessionIfIdle = () => {
if (activeUploadIds.current.size > 0) return;
const hasFailures = failedCountRef.current > 0;
failedCountRef.current = 0;
if (!hasFailures) {
onUploadSuccess?.();
}
};
const handleUploadProgress = (index: number, progress: number) => {
const handleUploadStart = (
newUploads: { uploadId: string; fileName: string; progress: number }[],
) => {
const isStartingFreshSession = activeUploadIds.current.size === 0;
if (isStartingFreshSession) {
failedCountRef.current = 0;
setRejectedFiles([]);
}
setUploads((prev) => [...prev, ...newUploads]);
newUploads.forEach((upload) => {
activeUploadIds.current.add(upload.uploadId);
const pendingId = newId("pending");
pendingUploadIds.current.set(upload.uploadId, pendingId);
addPendingUpload({
id: pendingId,
name: upload.fileName,
folderId: folderId ?? null,
uploadedAt: new Date(),
status: "uploading",
progress: 0,
});
});
};
const handleUploadProgress = (uploadId: string, progress: number) => {
setUploads((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], progress };
const updated = prev.map((upload) =>
upload.uploadId === uploadId ? { ...upload, progress } : upload,
);
const pendingId = pendingUploadIds.current.get(uploadId);
if (pendingId) {
updatePendingUpload(pendingId, { progress });
}
return updated;
});
};
const handleUploadComplete = async (documentData: DocumentData) => {
// Call your API to add the document to the dataroom or store it for viewer uploads
const settleUpload = (uploadId: string, failed: boolean) => {
if (failed) {
failedCountRef.current += 1;
}
activeUploadIds.current.delete(uploadId);
pendingUploadIds.current.delete(uploadId);
setUploads((prev) => prev.filter((upload) => upload.uploadId !== uploadId));
finalizeSessionIfIdle();
};
const handleUploadComplete = async (
documentData: DocumentData,
uploadId: string,
) => {
const pendingId = pendingUploadIds.current.get(uploadId);
// Update status to processing (file uploaded to S3, now being processed by backend)
if (pendingId) {
updatePendingUpload(pendingId, {
status: "processing",
progress: 100,
});
}
// Call the API to add the document to the dataroom
try {
const response = await fetch(`/api/links/${viewerData.linkId}/upload`, {
method: "POST",
@@ -56,66 +131,118 @@ export function ViewerUploadComponent({
throw new Error(errorData.message || "Failed to process upload");
}
// Optional: You might want to update UI or fetch updated document list
const result = await response.json();
if (!result.document) {
throw new Error("Upload response missing document metadata");
}
// Determine if the file needs trigger processing (PDF, docs, slides)
// Images and Excel/CSV files are ready immediately after upload
const FILE_TYPES_NEEDING_PROCESSING = ["pdf", "docs", "slides"];
const needsProcessing = FILE_TYPES_NEEDING_PROCESSING.includes(
result.document.fileType,
);
// Update pending upload with real document data
if (pendingId) {
updatePendingUpload(pendingId, {
status: needsProcessing ? "processing" : "complete",
documentId: result.document.id,
dataroomDocumentId: result.document.dataroomDocumentId,
documentVersionId: result.document.documentVersionId,
fileType: result.document.fileType,
});
}
settleUpload(uploadId, false);
} catch (error) {
console.error("Error processing upload:", error);
toast.error((error as Error).message || "Failed to upload document");
if (pendingId) {
updatePendingUpload(pendingId, {
status: "error",
errorMessage: (error as Error).message || "Failed to upload document",
});
}
settleUpload(uploadId, true);
}
};
const isUploading = uploads.length > 0;
return (
<div className="p-4">
<h1 className="mb-4 text-xl font-bold">Upload Documents</h1>
<ViewerUploadZone
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}
onUploadRejected={(rejected) => setRejectedFiles(rejected)}
viewerData={viewerData}
teamId={teamId}
>
<div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
<p>Drag & drop files here, or click to select files</p>
<p className="mt-2 text-sm text-gray-500">
Supported file types: PDF, Word, Excel, CSV
</p>
{/* Display current uploads */}
{uploads.length > 0 && (
<div className="mt-4">
<h3 className="font-medium">Uploads</h3>
<ul className="mt-2 space-y-2">
{uploads.map((upload, index) => (
<li key={index} className="text-sm">
{upload.fileName} - {upload.progress}%
<div className="mt-1 h-1 rounded-full bg-gray-200">
<div
className="h-1 rounded-full bg-blue-500"
style={{ width: `${upload.progress}%` }}
/>
</div>
</li>
))}
</ul>
<ViewerUploadZone
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}
onUploadRejected={(rejected) => setRejectedFiles(rejected)}
viewerData={viewerData}
teamId={teamId}
>
{isUploading ? (
<div className="space-y-3">
{uploads.map((upload) => (
<div
key={upload.uploadId}
className="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<FileUp className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{upload.fileName}
</p>
<div className="mt-1.5 flex items-center gap-2">
<Progress
value={upload.progress}
className="h-1.5 flex-1"
/>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{upload.progress}%
</span>
</div>
</div>
</div>
)}
{/* Display rejected files */}
{rejectedFiles.length > 0 && (
<div className="mt-4">
<h3 className="font-medium text-red-500">Rejected Files</h3>
<ul className="mt-2 space-y-1">
{rejectedFiles.map((file, index) => (
<li key={index} className="text-sm text-red-500">
{file.fileName}: {file.message}
</li>
))}
</ul>
</div>
)}
))}
</div>
</ViewerUploadZone>
</div>
) : (
<div
className={cn(
"flex flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors",
"border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500",
)}
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
<FileUp className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">
Drag & drop files here, or click to select files
</p>
<p className="mt-1 text-xs text-muted-foreground">
Supported file types: PDF, Excel, CSV, Images
</p>
</div>
)}
{/* Display rejected files */}
{rejectedFiles.length > 0 && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950/30">
<p className="text-xs font-medium text-red-600 dark:text-red-400">
Some files were rejected:
</p>
<ul className="mt-1.5 space-y-0.5">
{rejectedFiles.map((file, index) => (
<li
key={index}
className="text-xs text-red-500 dark:text-red-400"
>
{file.fileName}: {file.message}
</li>
))}
</ul>
</div>
)}
</ViewerUploadZone>
);
}
+32 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback } from "react";
import { DocumentStorageType } from "@prisma/client";
import { FileRejection, useDropzone } from "react-dropzone";
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { VIEWER_ACCEPTED_FILE_TYPES } from "@/lib/constants";
import { DocumentData } from "@/lib/documents/create-document";
import { viewerUpload } from "@/lib/files/viewer-tus-upload";
import { newId } from "@/lib/id-helper";
import { cn } from "@/lib/utils";
import { getSupportedContentType } from "@/lib/utils/get-content-type";
import { getPagesCount } from "@/lib/utils/get-page-number-count";
@@ -23,11 +24,14 @@ export default function ViewerUploadZone({
viewerData,
teamId,
maxFileSize = 30, // 30 MB default
disabled = false,
}: {
children: React.ReactNode;
onUploadStart: (uploads: { fileName: string; progress: number }[]) => void;
onUploadProgress: (index: number, progress: number) => void;
onUploadComplete: (documentData: DocumentData) => void;
onUploadStart: (
uploads: { uploadId: string; fileName: string; progress: number }[],
) => void;
onUploadProgress: (uploadId: string, progress: number) => void;
onUploadComplete: (documentData: DocumentData, uploadId: string) => void;
onUploadRejected: (rejected: { fileName: string; message: string }[]) => void;
viewerData: {
id: string;
@@ -36,10 +40,8 @@ export default function ViewerUploadZone({
};
teamId: string;
maxFileSize?: number;
disabled?: boolean;
}) {
const [progress, setProgress] = useState<number>(0);
const uploadProgress = useRef<number[]>([]);
const onDropRejected = useCallback(
(rejectedFiles: FileRejection[]) => {
const rejected = rejectedFiles.map(({ file, errors }) => {
@@ -58,14 +60,20 @@ export default function ViewerUploadZone({
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
const newUploads = acceptedFiles.map((file) => ({
const trackedFiles = acceptedFiles.map((file) => ({
uploadId: newId("pending"),
file,
}));
const newUploads = trackedFiles.map(({ uploadId, file }) => ({
uploadId,
fileName: file.name,
progress: 0,
}));
onUploadStart(newUploads);
const uploadPromises = acceptedFiles.map(async (file, index) => {
const uploadPromises = trackedFiles.map(async ({ uploadId, file }) => {
// count the number of pages in the file
let numPages = 1;
if (file.type === "application/pdf") {
@@ -76,18 +84,11 @@ export default function ViewerUploadZone({
const { complete } = await viewerUpload({
file,
onProgress: (bytesUploaded, bytesTotal) => {
uploadProgress.current[index] = (bytesUploaded / bytesTotal) * 100;
onUploadProgress(
index,
Math.min(Math.round(uploadProgress.current[index]), 99),
const progress = Math.min(
Math.round((bytesUploaded / bytesTotal) * 100),
99,
);
const _progress = uploadProgress.current.reduce(
(acc, progress) => acc + progress,
0,
);
setProgress(Math.round(_progress / acceptedFiles.length));
onUploadProgress(uploadId, progress);
},
onError: (error) => {
console.error("Upload error:", error);
@@ -134,10 +135,9 @@ export default function ViewerUploadZone({
numPages: numPages,
};
// Complete the upload by calling the provided callback
onUploadComplete(documentData);
onUploadComplete(documentData, uploadId);
onUploadProgress(index, 100); // Mark upload as complete
onUploadProgress(uploadId, 100); // Mark upload as complete
return uploadResult;
});
@@ -159,6 +159,7 @@ export default function ViewerUploadZone({
maxSize: maxFileSize * 1024 * 1024,
onDrop,
onDropRejected,
disabled,
});
return (
@@ -179,16 +180,14 @@ export default function ViewerUploadZone({
/>
{isDragActive && (
<div className="sticky top-1/2 z-50 -translate-y-1/2 px-2">
<div className="flex justify-center">
<div className="inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10">
<span className="font-medium text-foreground">
Drop your file(s) here
</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Only *.pdf, *.doc, *.docx, *.xls, *.xlsx, *.csv, *.ods files
</p>
</div>
<div className="flex h-full items-center justify-center">
<div className="inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10">
<span className="font-medium text-foreground">
Drop your file(s) here
</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods files
</p>
</div>
</div>
)}
+502
View File
@@ -0,0 +1,502 @@
import Link from "next/link";
import { useState } from "react";
import {
ArrowUpRightIcon,
ChevronRightIcon,
DownloadCloudIcon,
FileCheckIcon,
FileIcon,
UploadCloudIcon,
} from "lucide-react";
import { useDataroomVisitHistory } from "@/lib/swr/use-dataroom";
import {
DocumentViewStats,
useDataroomViewDocumentStats,
} from "@/lib/swr/use-dataroom-view-document-stats";
import { cn, durationFormat, timeAgo } from "@/lib/utils";
import { Gauge } from "@/components/ui/gauge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { TableCell, TableRow } from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { DocumentPageChart } from "./document-view-stats";
export function DataroomViewStats({
viewId,
dataroomId,
isExpanded,
}: {
viewId: string;
dataroomId: string;
isExpanded: boolean;
}) {
const { documentViews, uploadedDocumentViews } = useDataroomVisitHistory({
viewId,
dataroomId,
});
const { documentStats, loading: statsLoading } = useDataroomViewDocumentStats(
{
dataroomId,
dataroomViewId: viewId,
enabled: isExpanded,
},
);
const statsMap = new Map<string, DocumentViewStats>();
documentStats?.forEach((s) => {
statsMap.set(s.viewId, s);
});
const groupedViews = documentViews
? documentViews.reduce(
(acc, view) => {
if (view.downloadType === "BULK" || view.downloadType === "FOLDER") {
const key = `${view.downloadType}-${new Date(view.viewedAt).toISOString()}`;
if (!acc.bulkDownloads[key]) {
acc.bulkDownloads[key] = {
type: view.downloadType,
viewedAt: view.viewedAt,
downloadedAt: view.downloadedAt,
metadata: view.downloadMetadata,
documents: [],
};
}
acc.bulkDownloads[key].documents.push(view);
} else {
acc.individualViews.push(view);
}
return acc;
},
{
individualViews: [] as typeof documentViews,
bulkDownloads: {} as Record<
string,
{
type: string;
viewedAt: string;
downloadedAt: string;
metadata?: {
folderName?: string;
folderPath?: string;
dataroomName?: string;
documentCount?: number;
documents?: {
id: string;
name: string;
}[];
};
documents: typeof documentViews;
}
>,
},
)
: { individualViews: [], bulkDownloads: {} };
const allEvents: Array<{
type: "upload" | "view" | "download" | "bulk-download";
timestamp: Date;
data: any;
}> = [];
uploadedDocumentViews?.forEach((upload) => {
allEvents.push({
type: "upload",
timestamp: new Date(upload.uploadedAt),
data: upload,
});
});
Object.entries(groupedViews.bulkDownloads).forEach(([_key, bulkGroup]) => {
allEvents.push({
type: "bulk-download",
timestamp: new Date(bulkGroup.downloadedAt),
data: bulkGroup,
});
});
groupedViews.individualViews.forEach((view) => {
const viewedAtTime = new Date(view.viewedAt).getTime();
const downloadedAtTime = view.downloadedAt
? new Date(view.downloadedAt).getTime()
: null;
const isDownloadOnly =
downloadedAtTime && Math.abs(viewedAtTime - downloadedAtTime) < 1000;
if (!isDownloadOnly) {
allEvents.push({
type: "view",
timestamp: new Date(view.viewedAt),
data: view,
});
}
if (view.downloadedAt) {
allEvents.push({
type: "download",
timestamp: new Date(view.downloadedAt),
data: view,
});
}
});
allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return (
<>
{allEvents.length > 0 ? (
allEvents.map((event, index) => {
if (event.type === "upload") {
return (
<UploadRow
key={`upload-${event.data.documentId}-${index}`}
upload={event.data}
timestamp={event.timestamp}
/>
);
}
if (event.type === "bulk-download") {
return (
<BulkDownloadRow
key={`bulk-${index}`}
bulkGroup={event.data}
timestamp={event.timestamp}
/>
);
}
if (event.type === "view") {
const view = event.data;
const stats = statsMap.get(view.id);
return (
<DocumentViewRow
key={`view-${view.id}`}
view={view}
stats={stats}
statsLoading={statsLoading}
dataroomId={dataroomId}
dataroomViewId={viewId}
timestamp={event.timestamp}
/>
);
}
if (event.type === "download") {
const view = event.data;
return (
<DownloadRow
key={`download-${view.id}`}
view={view}
timestamp={event.timestamp}
/>
);
}
return null;
})
) : documentViews === undefined ? (
<TableRow className="[&>td]:py-3">
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-16" />
</TableCell>
<TableCell />
</TableRow>
) : null}
</>
);
}
function DocumentViewRow({
view,
stats,
statsLoading,
dataroomId,
dataroomViewId,
timestamp,
}: {
view: any;
stats: DocumentViewStats | undefined;
statsLoading: boolean;
dataroomId: string;
dataroomViewId: string;
timestamp: Date;
}) {
const [showPageByPage, setShowPageByPage] = useState(false);
const hasPages = stats && stats.totalPages > 0;
return (
<>
<TableRow className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<FileCheckIcon className="h-5 w-5 shrink-0 text-[#fb7a00]" />
<div className="flex items-center gap-x-2">
<span className="truncate">Viewed {view.document.name}</span>
<Link
href={`/documents/${view.document.id}`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowUpRightIcon className="h-4 w-4" />
</Link>
</div>
{hasPages && (
<button
onClick={() => setShowPageByPage((prev) => !prev)}
className="flex shrink-0 items-center gap-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon
className={cn(
"h-3 w-3 transition-transform",
showPageByPage && "rotate-90",
)}
/>
<span className="hidden sm:inline">page-by-page</span>
</button>
)}
</div>
</TableCell>
<TableCell>
{statsLoading ? (
<Skeleton className="h-4 w-14" />
) : stats ? (
<span className="text-sm text-muted-foreground">
{durationFormat(stats.totalDuration)}
</span>
) : null}
</TableCell>
<TableCell>
{statsLoading ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : stats ? (
<Gauge value={stats.completionRate} size={"xs"} showValue={true} />
) : null}
</TableCell>
<TableCell>
<TimestampTooltip
timestamp={timestamp}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none truncate text-sm text-muted-foreground"
dateTime={timestamp.toISOString()}
>
{timeAgo(timestamp)}
</time>
</TimestampTooltip>
</TableCell>
<TableCell />
</TableRow>
{showPageByPage && hasPages && (
<TableRow>
<TableCell colSpan={5} className="p-0 px-4 pb-3 pt-0">
<DocumentPageChart
dataroomId={dataroomId}
dataroomViewId={dataroomViewId}
documentViewId={view.id}
documentId={view.document.id}
totalPages={stats.totalPages}
downloadType={view.downloadType}
downloadMetadata={view.downloadMetadata}
/>
</TableCell>
</TableRow>
)}
</>
);
}
function UploadRow({ upload, timestamp }: { upload: any; timestamp: Date }) {
return (
<TableRow className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<UploadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
<span className="truncate">Uploaded {upload.originalFilename}</span>
<Link
href={`/documents/${upload.documentId}`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowUpRightIcon className="h-4 w-4" />
</Link>
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={timestamp}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none truncate text-sm text-muted-foreground"
dateTime={timestamp.toISOString()}
>
{timeAgo(timestamp)}
</time>
</TimestampTooltip>
</TableCell>
<TableCell />
</TableRow>
);
}
function DownloadRow({ view, timestamp }: { view: any; timestamp: Date }) {
return (
<TableRow className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<DownloadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
<span className="truncate">Downloaded {view.document.name}</span>
<Link
href={`/documents/${view.document.id}`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowUpRightIcon className="h-4 w-4" />
</Link>
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={timestamp}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none truncate text-sm text-muted-foreground"
dateTime={timestamp.toISOString()}
>
{timeAgo(timestamp)}
</time>
</TimestampTooltip>
</TableCell>
<TableCell />
</TableRow>
);
}
function BulkDownloadRow({
bulkGroup,
timestamp,
}: {
bulkGroup: any;
timestamp: Date;
}) {
const documentCount =
bulkGroup.metadata?.documentCount || bulkGroup.documents.length;
const hasDocumentList =
bulkGroup.metadata?.documents && bulkGroup.metadata.documents.length > 0;
return (
<TableRow className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<DownloadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
<span>
Downloaded{" "}
{hasDocumentList ? (
<Popover>
<PopoverTrigger asChild>
<button className="underline decoration-dotted hover:text-primary">
{documentCount} document
{documentCount !== 1 ? "s" : ""}
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="font-medium leading-none">
{bulkGroup.type === "FOLDER" &&
bulkGroup.metadata?.folderName
? `Files in ${bulkGroup.metadata.folderName}`
: `Files in ${bulkGroup.metadata?.dataroomName || "dataroom"}`}
</h4>
<ScrollArea className="h-[200px] w-full rounded-md border p-2">
<div className="space-y-1">
{bulkGroup.metadata.documents!.map(
(doc: { id: string; name: string }) => (
<div
key={doc.id}
className="flex items-center gap-2 text-sm"
>
<FileIcon className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{doc.name}</span>
</div>
),
)}
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
) : (
<>
{documentCount} document
{documentCount !== 1 ? "s" : ""}
</>
)}{" "}
{bulkGroup.type === "FOLDER" && bulkGroup.metadata?.folderName ? (
<>
from folder{" "}
<span className="font-medium">
{bulkGroup.metadata.folderName}
</span>
</>
) : bulkGroup.type === "BULK" &&
bulkGroup.metadata?.dataroomName ? (
<>
from{" "}
<span className="font-medium">
{bulkGroup.metadata.dataroomName}
</span>{" "}
dataroom
</>
) : (
"via bulk download"
)}
</span>
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={timestamp}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none truncate text-sm text-muted-foreground"
dateTime={timestamp.toISOString()}
>
{timeAgo(timestamp)}
</time>
</TimestampTooltip>
</TableCell>
<TableCell />
</TableRow>
);
}
+53 -36
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import {
BadgeCheckIcon,
BadgeInfoIcon,
@@ -27,7 +29,7 @@ import {
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { BadgeTooltip } from "@/components/ui/tooltip";
import DataroomVisitHistory from "./dataroom-visitors-history";
import { DataroomViewStats } from "./dataroom-view-stats";
import { VisitorAvatar } from "./visitor-avatar";
export default function DataroomViewersTable({
@@ -36,6 +38,21 @@ export default function DataroomViewersTable({
dataroomId: string;
}) {
const { viewers } = useDataroomViewers({ dataroomId });
const [expandedViewerIds, setExpandedViewerIds] = useState<Set<string>>(
new Set(),
);
const handleOpenChange = (viewerId: string, open: boolean) => {
setExpandedViewerIds((prev) => {
const next = new Set(prev);
if (open) {
next.add(viewerId);
} else {
next.delete(viewerId);
}
return next;
});
};
return (
<div className="w-full">
@@ -43,14 +60,18 @@ export default function DataroomViewersTable({
<h2 className="mb-2 md:mb-4">All dataroom visitors</h2>
</div>
<div className="rounded-md border">
<Table>
<Table className="table-fixed">
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
<TableHead>Last Viewed</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
<TableHead className="w-[120px]">
{expandedViewerIds.size > 0 ? "View Duration" : null}
</TableHead>
<TableHead className="w-[140px]">
{expandedViewerIds.size > 0 ? "View Completion" : null}
</TableHead>
<TableHead className="w-[120px]">Last Viewed</TableHead>
<TableHead className="w-[48px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -65,7 +86,11 @@ export default function DataroomViewersTable({
)}
{viewers ? (
viewers.map((viewer) => (
<Collapsible key={viewer.id} asChild>
<Collapsible
key={viewer.id}
asChild
onOpenChange={(open) => handleOpenChange(viewer.id, open)}
>
<>
<TableRow key={viewer.id} className="group/row">
{/* Name */}
@@ -112,29 +137,12 @@ export default function DataroomViewersTable({
{viewer.email}
</p>
)}
<p className="text-xs text-muted-foreground/60 sm:text-sm">
{/* {view.link.name ? view.link.name : view.linkId} */}
</p>
</div>
</div>
</div>
</TableCell>
{/* Duration */}
{/* <TableCell className="">
<div className="text-sm text-muted-foreground">
{durationFormat(view.totalDuration)}
</div>
</TableCell> */}
{/* Completion */}
{/* <TableCell className="flex justify-start">
<div className="text-sm text-muted-foreground">
<Gauge
value={view.completionRate}
size={"small"}
showValue={true}
/>
</div>
</TableCell> */}
<TableCell />
<TableCell />
{/* Last Viewed */}
<TableCell className="text-sm text-muted-foreground">
{viewer.lastViewedAt ? (
@@ -172,14 +180,15 @@ export default function DataroomViewersTable({
? viewer.views.map((view: any) => (
<CollapsibleContent asChild key={view.id}>
<>
<TableRow key={view.id}>
<TableRow key={view.id} className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<MailOpenIcon className="h-5 w-5 text-[#fb7a00]" />
Accessed {viewer.dataroomName} dataroom
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={view.viewedAt}
@@ -196,18 +205,22 @@ export default function DataroomViewersTable({
</time>
</TimestampTooltip>
</TableCell>
<TableCell className="table-cell"></TableCell>
<TableCell />
</TableRow>
{view.downloadedAt ? (
<TableRow key={`download-${view.id}`}>
<TableRow
key={`download-${view.id}`}
className="[&>td]:py-3"
>
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<DownloadCloudIcon className="h-5 w-5 text-cyan-500 hover:text-cyan-600" />
Downloaded {viewer.dataroomName} dataroom
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={view.downloadedAt}
@@ -224,13 +237,14 @@ export default function DataroomViewersTable({
</time>
</TimestampTooltip>
</TableCell>
<TableCell className="table-cell"></TableCell>
<TableCell />
</TableRow>
) : null}
<DataroomVisitHistory
<DataroomViewStats
viewId={view.id}
dataroomId={dataroomId}
isExpanded={expandedViewerIds.has(viewer.id)}
/>
</>
</CollapsibleContent>
@@ -244,14 +258,17 @@ export default function DataroomViewersTable({
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell className="min-w-[450px]">
<Skeleton className="h-6 w-full" />
<TableCell>
<Skeleton className="h-6 w-14" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6 rounded-full" />
</TableCell>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-6" />
</TableCell>
</TableRow>
)}
+55 -36
View File
@@ -38,9 +38,9 @@ import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { BadgeTooltip } from "@/components/ui/tooltip";
import { ExportVisitsModal } from "../datarooms/export-visits-modal";
import { DataroomViewStats } from "./dataroom-view-stats";
import DataroomVisitorCustomFields from "./dataroom-visitor-custom-fields";
import { DataroomVisitorUserAgent } from "./dataroom-visitor-useragent";
import DataroomVisitHistory from "./dataroom-visitors-history";
import { VisitorAvatar } from "./visitor-avatar";
export default function DataroomVisitorsTable({
@@ -61,11 +61,26 @@ export default function DataroomVisitorsTable({
const { dataroom } = useDataroom();
const { isPaused } = usePlan();
const [exportModalOpen, setExportModalOpen] = useState(false);
const [expandedViewIds, setExpandedViewIds] = useState<Set<string>>(
new Set(),
);
const exportVisitCounts = () => {
setExportModalOpen(true);
};
const handleOpenChange = (viewId: string, open: boolean) => {
setExpandedViewIds((prev) => {
const next = new Set(prev);
if (open) {
next.add(viewId);
} else {
next.delete(viewId);
}
return next;
});
};
return (
<div className="w-full">
<div className="mb-2 flex items-center justify-between md:mb-4">
@@ -76,14 +91,18 @@ export default function DataroomVisitorsTable({
</Button>
</div>
<div className="rounded-md border">
<Table>
<Table className="table-fixed">
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
<TableHead>Last Viewed</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
<TableHead className="w-[120px]">
{expandedViewIds.size > 0 ? "View Duration" : null}
</TableHead>
<TableHead className="w-[140px]">
{expandedViewIds.size > 0 ? "View Completion" : null}
</TableHead>
<TableHead className="w-[120px]">Last Viewed</TableHead>
<TableHead className="w-[48px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -99,7 +118,7 @@ export default function DataroomVisitorsTable({
{isPaused && hiddenFromPause > 0 && (
<>
<TableRow>
<TableCell colSpan={3} className="text-left sm:text-center">
<TableCell colSpan={5} className="text-left sm:text-center">
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
@@ -124,7 +143,11 @@ export default function DataroomVisitorsTable({
)}
{views ? (
views.map((view) => (
<Collapsible key={view.id} asChild>
<Collapsible
key={view.id}
asChild
onOpenChange={(open) => handleOpenChange(view.id, open)}
>
<>
<TableRow key={view.id} className="group/row">
{/* Name */}
@@ -186,22 +209,8 @@ export default function DataroomVisitorsTable({
</div>
</div>
</TableCell>
{/* Duration */}
{/* <TableCell className="">
<div className="text-sm text-muted-foreground">
{durationFormat(view.totalDuration)}
</div>
</TableCell> */}
{/* Completion */}
{/* <TableCell className="flex justify-start">
<div className="text-sm text-muted-foreground">
<Gauge
value={view.completionRate}
size={"small"}
showValue={true}
/>
</div>
</TableCell> */}
<TableCell />
<TableCell />
{/* Last Viewed */}
<TableCell className="text-sm text-muted-foreground">
<TimestampTooltip
@@ -230,7 +239,7 @@ export default function DataroomVisitorsTable({
<CollapsibleContent asChild>
<>
<TableRow>
<TableCell colSpan={3}>
<TableCell colSpan={5}>
<DataroomVisitorCustomFields
viewId={view.id}
teamId={view.teamId!}
@@ -239,14 +248,15 @@ export default function DataroomVisitorsTable({
<DataroomVisitorUserAgent viewId={view.id} />
</TableCell>
</TableRow>
<TableRow key={view.id}>
<TableRow key={view.id} className="[&>td]:py-3">
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<MailOpenIcon className="h-5 w-5 text-[#fb7a00]" />
Accessed {view.dataroomName} dataroom
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={view.viewedAt}
@@ -261,18 +271,22 @@ export default function DataroomVisitorsTable({
</time>
</TimestampTooltip>
</TableCell>
<TableCell className="table-cell"></TableCell>
<TableCell />
</TableRow>
{view.downloadedAt ? (
<TableRow key={`download-item-${view.id}`}>
<TableRow
key={`download-item-${view.id}`}
className="[&>td]:py-3"
>
<TableCell>
<div className="flex items-center gap-x-4 overflow-visible">
<DownloadCloudIcon className="h-5 w-5 text-cyan-500 hover:text-cyan-600" />
Downloaded {view.dataroomName} dataroom
</div>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<TimestampTooltip
timestamp={view.downloadedAt}
@@ -289,13 +303,14 @@ export default function DataroomVisitorsTable({
</time>
</TimestampTooltip>
</TableCell>
<TableCell className="table-cell"></TableCell>
<TableCell />
</TableRow>
) : null}
<DataroomVisitHistory
<DataroomViewStats
viewId={view.id}
dataroomId={dataroomId}
isExpanded={expandedViewIds.has(view.id)}
/>
</>
</CollapsibleContent>
@@ -307,14 +322,17 @@ export default function DataroomVisitorsTable({
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell className="min-w-[450px]">
<Skeleton className="h-6 w-full" />
<TableCell>
<Skeleton className="h-6 w-14" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6 rounded-full" />
</TableCell>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-6" />
</TableCell>
</TableRow>
)}
@@ -336,7 +354,6 @@ export default function DataroomVisitorsTable({
);
}
// create a component for a blurred view of the visitor
const VisitorBlurred = () => {
return (
<TableRow className="blur-sm">
@@ -355,6 +372,8 @@ const VisitorBlurred = () => {
</div>
</div>
</TableCell>
<TableCell />
<TableCell />
{/* Last Viewed */}
<TableCell className="text-sm text-muted-foreground">
<time
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { DownloadCloudIcon } from "lucide-react";
import BarChartComponent from "@/components/charts/bar-chart";
import StatsChartSkeleton from "@/components/documents/stats-chart-skeleton";
import { useDataroomDocumentPageStats } from "@/lib/swr/use-dataroom-view-document-stats";
export function DocumentPageChart({
dataroomId,
dataroomViewId,
documentViewId,
documentId,
totalPages,
downloadType,
downloadMetadata,
}: {
dataroomId: string;
dataroomViewId: string;
documentViewId: string;
documentId: string;
totalPages: number;
downloadType?: "SINGLE" | "BULK" | "FOLDER" | null;
downloadMetadata?: {
folderName?: string;
folderPath?: string;
dataroomName?: string;
documentCount?: number;
documents?: { id: string; name: string }[];
} | null;
}) {
const [fetchEnabled, setFetchEnabled] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
timerRef.current = setTimeout(() => {
setFetchEnabled(true);
}, 150);
return () => clearTimeout(timerRef.current);
}, []);
const { duration, loading } = useDataroomDocumentPageStats({
dataroomId,
dataroomViewId,
documentViewId,
documentId,
enabled: fetchEnabled,
});
if (loading || !duration) {
return <StatsChartSkeleton className="border-none px-0" />;
}
const hasViewData = duration.data.some((item) => item.sum_duration > 0);
if (!hasViewData && downloadType) {
let downloadMessage = "";
if (downloadType === "FOLDER" && downloadMetadata?.folderName) {
downloadMessage = `Downloaded without viewing via folder "${downloadMetadata.folderName}"`;
} else if (downloadType === "BULK") {
downloadMessage = "Downloaded without viewing via bulk download";
} else {
downloadMessage = "Downloaded without viewing";
}
return (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<DownloadCloudIcon className="h-4 w-4" />
<span>{downloadMessage}</span>
</div>
);
}
let durationData = Array.from({ length: totalPages }, (_, i) => ({
pageNumber: (i + 1).toString(),
sum_duration: 0,
}));
durationData = durationData.map((item) => {
const match = duration.data.find((d) => d.pageNumber === item.pageNumber);
return match || item;
});
return (
<div className="pb-0.5 pl-0.5 md:pb-1 md:pl-1">
<BarChartComponent data={durationData} isSum={true} documentId={documentId} />
</div>
);
}
+73 -8
View File
@@ -33,6 +33,7 @@ export default function DataroomTrial() {
const [useCase, setUseCase] = useState<string>("");
const [customUseCase, setCustomUseCase] = useState<string>("");
const [dealSize, setDealSize] = useState<string>("");
const [companySize, setCompanySize] = useState<string>("");
const [name, setName] = useState<string>("");
const [companyName, setCompanyName] = useState<string>("");
@@ -40,6 +41,21 @@ export default function DataroomTrial() {
const [loading, setLoading] = useState<boolean>(false);
// Map use case to deal type for survey
const useCaseToDealType: Record<string, string> = {
"mergers-and-acquisitions": "mergers-acquisitions",
"startup-fundraising": "startup-fundraising",
"fund-management": "fund-management",
sales: "sales",
"project-management": "project-management",
operations: "financial-operations",
"real-estate": "real-estate",
};
// Check if use case needs deal size question
const needsDealSize =
!!useCase && useCase !== "project-management";
// Helper function to convert use case to proper dataroom name
const getDataroomName = (useCaseValue: string, customValue: string = "") => {
if (useCaseValue === "other" && customValue) {
@@ -47,12 +63,13 @@ export default function DataroomTrial() {
}
const useCaseNames: Record<string, string> = {
"mergers-and-acquisitions": "Mergers and Acquisitions Data Room",
"mergers-and-acquisitions": "Mergers & Acquisitions Data Room",
"startup-fundraising": "Startup Fundraising Data Room",
"fund-management": "Fund Management & Fundraising Data Room",
"fund-management": "Fundraising & Reporting Data Room",
sales: "Sales Data Room",
"project-management": "Project Management Data Room",
operations: "Operations Data Room",
operations: "Financial Operations Data Room",
"real-estate": "Real Estate Data Room",
};
return useCaseNames[useCaseValue] || "Data Room";
@@ -67,11 +84,31 @@ export default function DataroomTrial() {
return;
}
// Check if deal size is required but not filled
if (needsDealSize && !dealSize) {
toast.error("Please select a deal size.");
return;
}
setLoading(true);
const dataroomName = getDataroomName(useCase, customUseCase.trim());
try {
// Save survey data to team
const dealType = useCase === "other" ? "other" : useCaseToDealType[useCase];
if (dealType) {
await fetch(`/api/teams/${teamInfo?.currentTeam?.id}/survey`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dealType,
dealTypeOther: useCase === "other" ? customUseCase.trim() : null,
dealSize: dealSize || null,
}),
});
}
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,
{
@@ -107,6 +144,7 @@ export default function DataroomTrial() {
dataroomName: dataroomName,
useCase: useCase === "other" ? customUseCase.trim() : useCase,
companySize,
dealSize,
dataroomId,
});
toast.success("Dataroom successfully created! 🎉");
@@ -250,6 +288,8 @@ export default function DataroomTrial() {
if (value !== "other") {
setCustomUseCase("");
}
// Reset deal size when use case changes
setDealSize("");
}}
>
<SelectTrigger>
@@ -257,19 +297,20 @@ export default function DataroomTrial() {
</SelectTrigger>
<SelectContent>
<SelectItem value="mergers-and-acquisitions">
Mergers and Acquisitions
Mergers & Acquisitions
</SelectItem>
<SelectItem value="startup-fundraising">
Startup Fundraising
</SelectItem>
<SelectItem value="fund-management">
Fund management & Fundraising
Fundraising & Reporting
</SelectItem>
<SelectItem value="sales">Sales</SelectItem>
<SelectItem value="project-management">
Project management
Project Management
</SelectItem>
<SelectItem value="operations">Operations</SelectItem>
<SelectItem value="operations">Financial Operations</SelectItem>
<SelectItem value="real-estate">Real Estate</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
@@ -285,6 +326,29 @@ export default function DataroomTrial() {
)}
</div>
{/* Deal Size - shown after use case is selected (except project-management and operations) */}
{needsDealSize && (
<div className="space-y-1">
<Label className="opacity-80">
{useCase === "startup-fundraising" || useCase === "fund-management"
? "How much are you raising?*"
: "What's the deal size?*"}
</Label>
<Select onValueChange={(value) => setDealSize(value)}>
<SelectTrigger>
<SelectValue placeholder="Select deal size" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0-500k">$0 - $500K</SelectItem>
<SelectItem value="500k-5m">$500K - $5M</SelectItem>
<SelectItem value="5m-10m">$5M - $10M</SelectItem>
<SelectItem value="10m-100m">$10M - $100M</SelectItem>
<SelectItem value="100m+">$100M+</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="tools" className="opacity-80">
Tools*
@@ -318,7 +382,8 @@ export default function DataroomTrial() {
!useCase ||
!name ||
!companyName ||
(useCase === "other" && !customUseCase.trim())
(useCase === "other" && !customUseCase.trim()) ||
(needsDealSize && !dealSize)
}
loading={loading}
>
+263 -55
View File
@@ -1,5 +1,8 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { FileChartPieIcon, FileIcon, PresentationIcon } from "lucide-react";
import { motion } from "motion/react";
@@ -7,8 +10,143 @@ import { STAGGER_CHILD_VARIANTS } from "@/lib/constants";
import NotionIcon from "@/components/shared/icons/files/notion";
export default function Next() {
const DEAL_TYPE_OPTIONS = [
{ value: "startup-fundraising", label: "Startup Fundraising" },
{ value: "fund-management", label: "Fundraising & Reporting" },
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
{ value: "financial-operations", label: "Financial Operations" },
{ value: "real-estate", label: "Real Estate" },
{ value: "project-management", label: "Project Management" },
];
const DEAL_SIZE_OPTIONS = [
{ value: "0-500k", label: "$0-500K" },
{ value: "500k-5m", label: "$500K-5M" },
{ value: "5m-10m", label: "$5M-10M" },
{ value: "10m-100m", label: "$10M-100M" },
{ value: "100m+", label: "$100M+" },
];
export default function Select() {
const router = useRouter();
const teamInfo = useTeam();
const [selectedDoc, setSelectedDoc] = useState<string | null>(null);
const [dealType, setDealType] = useState<string | null>(null);
const [dealTypeOther, setDealTypeOther] = useState<string>("");
const [showOtherInput, setShowOtherInput] = useState(false);
const [dealSize, setDealSize] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Determine which survey step to show based on document type
const needsDealTypeQuestion =
selectedDoc === "notion" || selectedDoc === "document";
const needsDealSizeQuestion =
selectedDoc === "pitchdeck" ||
selectedDoc === "sales-document" ||
(needsDealTypeQuestion && dealType && dealType !== "project-management");
const showDealTypeOptions = needsDealTypeQuestion && !dealType && !showOtherInput;
const showDealSizeOptions =
needsDealSizeQuestion &&
(dealType || !needsDealTypeQuestion) &&
!showOtherInput;
const handleDocSelect = (docType: string) => {
setSelectedDoc(docType);
setDealType(null);
setDealTypeOther("");
setShowOtherInput(false);
setDealSize(null);
// Auto-set deal type for specific document types
if (docType === "pitchdeck") {
setDealType("startup-fundraising");
} else if (docType === "sales-document") {
setDealType("sales");
}
};
const handleDealTypeSelect = (value: string) => {
if (isSubmitting) return;
setDealType(value);
setShowOtherInput(false);
if (value === "project-management") {
setIsSubmitting(true);
saveSurveyAndProceed(value, null, null);
}
};
const handleOtherConfirm = () => {
if (!dealTypeOther.trim()) return;
setDealType("other");
setShowOtherInput(false);
// Show deal size question after confirming "Other"
};
const handleDealSizeSelect = (value: string) => {
if (isSubmitting) return;
setDealSize(value);
setIsSubmitting(true);
saveSurveyAndProceed(dealType, value, dealTypeOther || null);
};
const saveSurveyAndProceed = async (
type: string | null,
size: string | null,
otherText: string | null,
) => {
setIsSubmitting(true);
try {
if (teamInfo?.currentTeam?.id && type) {
const res = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/survey`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dealType: type,
dealTypeOther: otherText,
dealSize: size,
}),
},
);
if (!res.ok) {
throw new Error(`Survey save failed: ${res.status}`);
}
}
await router.push({
pathname: "/welcome",
query: { type: selectedDoc },
});
} catch (error) {
console.error("Failed to save survey:", error);
} finally {
setIsSubmitting(false);
}
};
const getDealSizeQuestion = () => {
if (
selectedDoc === "pitchdeck" ||
dealType === "startup-fundraising" ||
dealType === "fund-management"
) {
return "How much are you raising?";
}
if (
dealType === "mergers-acquisitions" ||
dealType === "real-estate" ||
dealType === "financial-operations" ||
dealType === "other"
) {
return "What's the deal size?";
}
return "What's the typical deal size?";
};
return (
<motion.div
className="z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto"
@@ -38,88 +176,158 @@ export default function Next() {
Which document do you want to share today?
</h1>
</motion.div>
<motion.div
variants={STAGGER_CHILD_VARIANTS}
className="grid w-full grid-cols-1 divide-y divide-border rounded-md border border-border text-foreground md:grid-cols-4 md:divide-x"
>
<button
onClick={() =>
router.push({
pathname: "/welcome",
query: {
type: "pitchdeck",
},
})
}
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
onClick={() => handleDocSelect("pitchdeck")}
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
selectedDoc === "pitchdeck"
? "bg-primary/5 ring-2 ring-inset ring-primary"
: "hover:bg-gray-200 hover:dark:bg-gray-800"
}`}
>
<PresentationIcon className="pointer-events-none h-auto w-12 sm:w-12" />
<p>Pitchdeck</p>
</button>
<button
onClick={() =>
router.push({
pathname: "/welcome",
query: {
type: "sales-document",
},
})
}
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
onClick={() => handleDocSelect("sales-document")}
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
selectedDoc === "sales-document"
? "bg-primary/5 ring-2 ring-inset ring-primary"
: "hover:bg-gray-200 hover:dark:bg-gray-800"
}`}
>
<FileChartPieIcon className="pointer-events-none h-auto w-12 sm:w-12" />
<p>Sales document</p>
</button>
<button
onClick={() =>
router.push({
pathname: "/welcome",
query: {
type: "notion",
},
})
}
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
onClick={() => handleDocSelect("notion")}
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
selectedDoc === "notion"
? "bg-primary/5 ring-2 ring-inset ring-primary"
: "hover:bg-gray-200 hover:dark:bg-gray-800"
}`}
>
<NotionIcon className="pointer-events-none h-auto w-12 sm:w-12" />
<p>Notion Page</p>
</button>
<button
onClick={() =>
router.push({
pathname: "/welcome",
query: {
type: "document",
},
})
}
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
onClick={() => handleDocSelect("document")}
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
selectedDoc === "document"
? "bg-primary/5 ring-2 ring-inset ring-primary"
: "hover:bg-gray-200 hover:dark:bg-gray-800"
}`}
>
<FileIcon className="pointer-events-none h-auto w-12 sm:w-12" />
<p>Another document</p>
</button>
</motion.div>
<motion.div
variants={STAGGER_CHILD_VARIANTS}
className="text-center text-sm text-muted-foreground"
>
{/* <button
className="text-center text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={() =>
router.push({
pathname: "/welcome",
query: {
type: "dataroom",
},
})
}
> */}
{/* You can start by sharing documents and create a data room later. */}
{/* </button> */}
</motion.div>
{/* Inline Survey Questions */}
{selectedDoc && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-xl space-y-4"
>
{/* Deal Type Question (for notion/document) */}
{showDealTypeOptions && (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
What do you use Papermark for?
</p>
<div className="flex flex-wrap justify-center gap-2">
{DEAL_TYPE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealTypeSelect(option.value)}
disabled={isSubmitting}
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
>
{option.label}
</button>
))}
<button
onClick={() => setShowOtherInput(true)}
disabled={isSubmitting}
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
>
Other
</button>
</div>
</div>
)}
{/* Other Input Field - inline */}
{showOtherInput && !dealType && (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
What do you use Papermark for?
</p>
<div className="flex items-center justify-center gap-2">
<input
type="text"
value={dealTypeOther}
onChange={(e) => setDealTypeOther(e.target.value)}
placeholder="Please specify..."
className="max-w-xs rounded-full border border-border bg-background px-4 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && dealTypeOther.trim()) {
handleOtherConfirm();
}
}}
/>
<button
onClick={handleOtherConfirm}
disabled={!dealTypeOther.trim() || isSubmitting}
className="rounded-full border border-primary bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-50"
>
</button>
</div>
</div>
)}
{/* Deal Size Question */}
{showDealSizeOptions && !showDealTypeOptions && (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
{getDealSizeQuestion()}
</p>
<div className="flex flex-wrap justify-center gap-2">
{DEAL_SIZE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleDealSizeSelect(option.value)}
disabled={isSubmitting}
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
>
{option.label}
</button>
))}
</div>
</div>
)}
{/* Skip option */}
<button
onClick={() => saveSurveyAndProceed(dealType, null, dealTypeOther || null)}
disabled={isSubmitting}
aria-disabled={isSubmitting}
className="text-xs text-muted-foreground underline-offset-4 hover:underline disabled:opacity-50 disabled:pointer-events-none"
>
Skip
</button>
</motion.div>
)}
</motion.div>
);
}
+217
View File
@@ -0,0 +1,217 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type PendingUploadDocument = {
id: string;
name: string;
folderId: string | null;
uploadedAt: Date;
status: "uploading" | "processing" | "complete" | "error";
progress: number;
documentId?: string;
dataroomDocumentId?: string;
/** Version ID used to subscribe to Trigger.dev realtime processing updates */
documentVersionId?: string;
fileType?: string;
errorMessage?: string;
/** Whether this upload was loaded from the server (persisted) */
persisted?: boolean;
};
export type PendingUploadsContextType = {
pendingUploads: PendingUploadDocument[];
addPendingUpload: (upload: PendingUploadDocument) => void;
updatePendingUpload: (
id: string,
update: Partial<PendingUploadDocument>,
) => void;
removePendingUpload: (id: string) => void;
clearCompletedUploads: () => void;
getPendingUploadsForFolder: (
folderId: string | null,
) => PendingUploadDocument[];
/** Returns all uploads (in-flight + persisted) */
getAllUploads: () => PendingUploadDocument[];
/** Whether the visitor has any uploads */
hasUploads: boolean;
/** Whether persisted uploads are still loading */
isLoading: boolean;
};
export const initialState: PendingUploadsContextType = {
pendingUploads: [],
addPendingUpload: () => {},
updatePendingUpload: () => {},
removePendingUpload: () => {},
clearCompletedUploads: () => {},
getPendingUploadsForFolder: () => [],
getAllUploads: () => [],
hasUploads: false,
isLoading: false,
};
const PendingUploadsContext =
createContext<PendingUploadsContextType>(initialState);
export const PendingUploadsProvider = ({
children,
linkId,
dataroomId,
}: {
children: React.ReactNode;
linkId?: string;
dataroomId?: string;
}): JSX.Element => {
// In-flight uploads from the current session (uploading, processing, etc.)
const [pendingUploads, setPendingUploads] = useState<
PendingUploadDocument[]
>([]);
// Persisted uploads loaded from the server
const [persistedUploads, setPersistedUploads] = useState<
PendingUploadDocument[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchedRef = useRef(false);
// Fetch the viewer's persisted uploads on mount
useEffect(() => {
if (!linkId || !dataroomId || fetchedRef.current) return;
fetchedRef.current = true;
const fetchUploads = async () => {
setIsLoading(true);
try {
const res = await fetch(
`/api/links/${linkId}/upload?dataroomId=${dataroomId}`,
);
if (!res.ok) return;
const data = await res.json();
if (data.uploads && Array.isArray(data.uploads)) {
const loaded: PendingUploadDocument[] = data.uploads.map(
(u: any) => ({
id: u.id,
name: u.name,
folderId: u.folderId,
uploadedAt: new Date(u.uploadedAt),
status: u.status as "complete" | "processing",
progress: 100,
documentId: u.documentId,
dataroomDocumentId: u.dataroomDocumentId,
documentVersionId: u.documentVersionId ?? undefined,
fileType: u.fileType,
persisted: true,
}),
);
setPersistedUploads(loaded);
}
} catch (err) {
console.error("Failed to fetch viewer uploads:", err);
} finally {
setIsLoading(false);
}
};
fetchUploads();
}, [linkId, dataroomId]);
const addPendingUpload = useCallback((upload: PendingUploadDocument) => {
setPendingUploads((prev) => [...prev, upload]);
}, []);
const updatePendingUpload = useCallback(
(id: string, update: Partial<PendingUploadDocument>) => {
setPendingUploads((prev) =>
prev.map((upload) =>
upload.id === id ? { ...upload, ...update } : upload,
),
);
setPersistedUploads((prev) =>
prev.map((upload) =>
upload.id === id ? { ...upload, ...update } : upload,
),
);
},
[],
);
const removePendingUpload = useCallback((id: string) => {
setPendingUploads((prev) => prev.filter((upload) => upload.id !== id));
}, []);
const clearCompletedUploads = useCallback(() => {
setPendingUploads((prev) =>
prev.filter((upload) => upload.status !== "complete"),
);
}, []);
// Merge in-flight and persisted uploads, deduplicating by documentId
const allUploads = useMemo(() => {
// In-flight uploads take priority (they have real-time status)
const inFlightDocIds = new Set(
pendingUploads
.filter((u) => u.documentId)
.map((u) => u.documentId),
);
// Filter out persisted uploads that already have an in-flight version
const filteredPersisted = persistedUploads.filter(
(u) => !inFlightDocIds.has(u.documentId),
);
return [...pendingUploads, ...filteredPersisted];
}, [pendingUploads, persistedUploads]);
const getPendingUploadsForFolder = useCallback(
(folderId: string | null) => {
return allUploads.filter((upload) => upload.folderId === folderId);
},
[allUploads],
);
const getAllUploads = useCallback(() => {
return allUploads;
}, [allUploads]);
const hasUploads = allUploads.length > 0;
const value = useMemo(
() => ({
pendingUploads: allUploads,
addPendingUpload,
updatePendingUpload,
removePendingUpload,
clearCompletedUploads,
getPendingUploadsForFolder,
getAllUploads,
hasUploads,
isLoading,
}),
[
allUploads,
addPendingUpload,
updatePendingUpload,
removePendingUpload,
clearCompletedUploads,
getPendingUploadsForFolder,
getAllUploads,
hasUploads,
isLoading,
],
);
return (
<PendingUploadsContext.Provider value={value}>
{children}
</PendingUploadsContext.Provider>
);
};
export const usePendingUploads = () => useContext(PendingUploadsContext);
+262 -55
View File
@@ -1,14 +1,21 @@
"use client";
import { memo, useState } from "react";
import { type ComponentPropsWithoutRef, memo, useMemo, useState } from "react";
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
ThumbsDownIcon,
ThumbsUpIcon,
FileIcon,
FileSpreadsheetIcon,
FileTextIcon,
FolderIcon,
ImageIcon,
PresentationIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Message,
MessageAction,
@@ -18,6 +25,8 @@ import {
} from "@/components/ai-elements/message";
import { Shimmer } from "@/components/ai-elements/shimmer";
import type { ChatStreamSource } from "../lib/chat/send-message";
// ============================================================================
// Types
// ============================================================================
@@ -29,10 +38,214 @@ export interface ChatMessageProps {
className?: string;
messageId?: string;
onFeedback?: (messageId: string, feedback: "up" | "down") => void;
sources?: ChatStreamSource[];
suggestedQuestions?: string[];
onSuggestedQuestionClick?: (question: string) => void;
isLastAssistantMessage?: boolean;
}
type FeedbackState = "up" | "down" | null;
function getViewerBasePath(): string {
if (typeof window === "undefined") return "";
const path = window.location.pathname;
const dIndex = path.indexOf("/d/");
return dIndex !== -1 ? path.slice(0, dIndex) : path;
}
function buildDocumentUrl(
dataroomDocumentId: string,
page?: number,
): string {
const base = getViewerBasePath();
const query = page ? `?p=${page}` : "";
return `${base}/d/${dataroomDocumentId}${query}`;
}
function normalizeAssistantLinkHref(href?: string): string | undefined {
if (!href) {
return href;
}
if (href.startsWith("/")) {
const dIndex = href.indexOf("/d/");
if (dIndex !== -1) {
const docSegment = href.slice(dIndex);
return `${getViewerBasePath()}${docSegment}`;
}
return href;
}
try {
const parsed = new URL(href);
const dIndex = parsed.pathname.indexOf("/d/");
if (dIndex !== -1) {
const docSegment = parsed.pathname.slice(dIndex);
return `${getViewerBasePath()}${docSegment}${parsed.search}${parsed.hash}`;
}
} catch {
// Keep original href if URL parsing fails.
}
return href;
}
function AssistantLink({
children,
className,
href,
...props
}: ComponentPropsWithoutRef<"a">) {
const normalizedHref = normalizeAssistantLinkHref(href);
return (
<a
{...props}
className={cn("inline-flex items-center", className)}
href={normalizedHref}
rel="noopener noreferrer"
target="_blank"
>
{children}
</a>
);
}
// ============================================================================
// File type icon helper
// ============================================================================
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase();
switch (ext) {
case "pdf":
return <FileTextIcon className="size-3.5 text-red-500" />;
case "ppt":
case "pptx":
return <PresentationIcon className="size-3.5 text-orange-500" />;
case "xls":
case "xlsx":
case "csv":
return <FileSpreadsheetIcon className="size-3.5 text-green-600" />;
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return <ImageIcon className="size-3.5 text-purple-500" />;
default:
return <FileIcon className="size-3.5 text-blue-500" />;
}
}
// ============================================================================
// Sources Section
// ============================================================================
function SourcesSection({ sources }: { sources: ChatStreamSource[] }) {
const [expanded, setExpanded] = useState(false);
if (sources.length === 0) return null;
return (
<div className="!mt-3 rounded-lg border border-gray-200 bg-gray-50/50">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100/50"
>
<span>
Source: {sources.length}{" "}
{sources.length === 1 ? "document" : "documents"}
</span>
<ChevronDownIcon
className={cn(
"size-3.5 text-gray-400 transition-transform duration-200",
expanded && "rotate-180",
)}
/>
</button>
{expanded && (
<div className="border-t border-gray-200 px-3 py-2">
<div className="space-y-1.5">
{sources.map((source) => (
<a
key={`${source.id}-${source.page}`}
href={
source.dataroomDocumentId
? buildDocumentUrl(source.dataroomDocumentId, source.page)
: normalizeAssistantLinkHref(source.url)
}
target="_blank"
rel="noopener noreferrer"
className="group flex items-start gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-gray-100"
>
<span className="mt-0.5 shrink-0 text-[10px] font-semibold text-gray-400">
{source.id}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{getFileIcon(source.name)}
<span className="truncate text-xs font-medium text-gray-900 group-hover:text-primary">
{source.name}
</span>
{source.page && (
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
Page {source.page}
</span>
)}
</div>
{source.folderPath && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-gray-400">
<FolderIcon className="size-2.5" />
<span className="truncate">{source.folderPath}</span>
</div>
)}
</div>
</a>
))}
</div>
</div>
)}
</div>
);
}
// ============================================================================
// Suggested Questions
// ============================================================================
function SuggestedQuestions({
questions,
onQuestionClick,
}: {
questions: string[];
onQuestionClick?: (question: string) => void;
}) {
if (questions.length === 0) return null;
return (
<div className="!mt-5">
<p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-gray-400">
Suggested questions
</p>
<div className="space-y-1">
{questions.map((question, idx) => (
<button
key={idx}
type="button"
onClick={() => onQuestionClick?.(question)}
className="block w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-gray-900"
>
{question}
</button>
))}
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
@@ -44,10 +257,20 @@ export const ChatMessage = memo(function ChatMessage({
className,
messageId,
onFeedback,
sources,
suggestedQuestions,
onSuggestedQuestionClick,
isLastAssistantMessage,
}: ChatMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const isUser = role === "user";
const responseComponents = useMemo(
() => ({
a: AssistantLink,
}),
[],
);
const handleCopy = async () => {
try {
@@ -60,7 +283,6 @@ export const ChatMessage = memo(function ChatMessage({
};
const handleFeedback = (type: "up" | "down") => {
// Toggle feedback if clicking the same button, otherwise set new feedback
const newFeedback = feedback === type ? null : type;
setFeedback(newFeedback);
@@ -69,7 +291,6 @@ export const ChatMessage = memo(function ChatMessage({
}
};
// For user messages, simple rendering
if (isUser) {
return (
<Message from="user" className={className}>
@@ -80,7 +301,6 @@ export const ChatMessage = memo(function ChatMessage({
);
}
// For assistant messages
return (
<div className="space-y-1">
<Message from="assistant" className={className}>
@@ -89,62 +309,49 @@ export const ChatMessage = memo(function ChatMessage({
<Shimmer duration={1.5}>Generating response...</Shimmer>
) : (
<div className="prose prose-sm max-w-none dark:prose-invert">
<MessageResponse>{content}</MessageResponse>
<MessageResponse
components={responseComponents}
linkSafety={{ enabled: false }}
>
{content}
</MessageResponse>
</div>
)}
</MessageContent>
</Message>
{/* Message actions - always visible when there's content and not streaming */}
{content && !isStreaming && (
<MessageActions className="ml-0">
<MessageAction
onClick={handleCopy}
tooltip={copied ? "Copied!" : "Copy"}
label={copied ? "Copied" : "Copy message"}
variant="ghost"
size="icon"
className="size-7"
>
{copied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5 text-muted-foreground" />
<>
<MessageActions className="ml-0">
<MessageAction
onClick={handleCopy}
tooltip={copied ? "Copied!" : "Copy"}
label={copied ? "Copied" : "Copy message"}
variant="ghost"
size="icon"
className="size-7"
>
{copied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5 text-muted-foreground" />
)}
</MessageAction>
</MessageActions>
{sources && sources.length > 0 && (
<SourcesSection sources={sources} />
)}
{isLastAssistantMessage &&
suggestedQuestions &&
suggestedQuestions.length > 0 && (
<SuggestedQuestions
questions={suggestedQuestions}
onQuestionClick={onSuggestedQuestionClick}
/>
)}
</MessageAction>
{/* <MessageAction
onClick={() => handleFeedback("up")}
tooltip="Good response"
label="Good response"
variant="ghost"
size="icon"
className="size-7"
>
<ThumbsUpIcon
className={`size-3.5 ${
feedback === "up"
? "fill-green-500 text-green-500"
: "text-muted-foreground"
}`}
/>
</MessageAction>
<MessageAction
onClick={() => handleFeedback("down")}
tooltip="Bad response"
label="Bad response"
variant="ghost"
size="icon"
className="size-7"
>
<ThumbsDownIcon
className={`size-3.5 ${
feedback === "down"
? "fill-red-500 text-red-500"
: "text-muted-foreground"
}`}
/>
</MessageAction> */}
</MessageActions>
</>
)}
</div>
);
+188 -28
View File
@@ -6,8 +6,11 @@ import {
AtSignIcon,
FileTextIcon,
Loader2,
MessageSquareTextIcon,
Plus,
PlusIcon,
SearchIcon,
ShieldCheckIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
@@ -36,6 +39,14 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
ChatStreamMetadata,
ChatStreamSource,
} from "../lib/chat/send-message";
import {
CHAT_METADATA_PREFIX,
CHAT_METADATA_SUFFIX,
} from "../lib/chat/send-message";
import { parseTextStream } from "../lib/stream/parse-text-stream";
import ChatMessage from "./chat-message";
import {
@@ -52,6 +63,67 @@ interface Message {
role: "user" | "assistant";
content: string;
isStreaming?: boolean;
sources?: ChatStreamSource[];
suggestedQuestions?: string[];
}
function extractStreamMetadata(content: string): {
text: string;
metadata?: ChatStreamMetadata;
} {
const prefixIdx = content.indexOf(CHAT_METADATA_PREFIX);
if (prefixIdx === -1) return { text: content };
const jsonStart = prefixIdx + CHAT_METADATA_PREFIX.length;
const suffixIdx = content.indexOf(CHAT_METADATA_SUFFIX, jsonStart);
if (suffixIdx === -1) return { text: content };
try {
const jsonStr = content.slice(jsonStart, suffixIdx);
const metadata = JSON.parse(jsonStr) as ChatStreamMetadata;
const text = content.slice(0, prefixIdx).trim();
return { text, metadata };
} catch {
return { text: content };
}
}
const REFERENCES_REGEX =
/\n\n(?:#{1,6}\s*)?References\s*\n(?:\s*\n)?((?:[-*]\s.*(?:\n|$))+)$/i;
const REFERENCE_LINE_REGEX = /[-*]\s*\[(.+?)]\((.+?)\)(?:\s*-\s*p\.\s*(\d+))?/;
interface ResolvedRef {
dataroomDocumentId: string;
documentName: string;
url: string;
page?: number;
folderPath?: string;
}
function parseMarkdownReferences(content: string): {
text: string;
sources?: ChatStreamSource[];
} {
const match = content.match(REFERENCES_REGEX);
if (!match) return { text: content };
const text = content.replace(REFERENCES_REGEX, "").trim();
const lines = match[1].trim().split("\n").filter(Boolean);
const sources: ChatStreamSource[] = [];
for (let i = 0; i < lines.length; i++) {
const lineMatch = lines[i].match(REFERENCE_LINE_REGEX);
if (lineMatch) {
sources.push({
id: `D-${i + 1}`,
name: lineMatch[1].replace(/\\([\[\]])/g, "$1"),
url: lineMatch[2],
page: lineMatch[3] ? parseInt(lineMatch[3]) : undefined,
});
}
}
return { text, sources: sources.length > 0 ? sources : undefined };
}
interface ViewerChatPanelProps {
@@ -246,12 +318,43 @@ function ViewerChatPanelContent({
setChatId(selectedChatId);
setChatTitle(data.title);
// Convert messages to the format expected by the UI
const loadedMessages: Message[] = data.messages.map((msg: any) => ({
id: msg.id,
role: msg.role,
content: msg.content,
}));
const loadedMessages: Message[] = data.messages.map((msg: any) => {
if (msg.role === "assistant") {
const meta = msg.metadata as Record<string, any> | null;
const storedSuggestions = meta?.suggestedQuestions as
| string[]
| undefined;
const storedRefs = meta?.references as ResolvedRef[] | undefined;
if (storedRefs && storedRefs.length > 0) {
const { text } = parseMarkdownReferences(msg.content);
return {
id: msg.id,
role: msg.role,
content: text,
sources: storedRefs.map((ref: ResolvedRef, idx: number) => ({
id: `D-${idx + 1}`,
name: ref.documentName,
url: ref.url,
dataroomDocumentId: ref.dataroomDocumentId,
page: ref.page,
folderPath: ref.folderPath,
})),
suggestedQuestions: storedSuggestions,
};
}
const { text, sources } = parseMarkdownReferences(msg.content);
return {
id: msg.id,
role: msg.role,
content: text,
sources,
suggestedQuestions: storedSuggestions,
};
}
return { id: msg.id, role: msg.role, content: msg.content };
});
setMessages(loadedMessages);
} catch (error) {
@@ -355,20 +458,27 @@ function ViewerChatPanelContent({
};
setMessages((prev) => [...prev, assistantMessage]);
// Parse the text stream
await parseTextStream(reader, {
onTextDelta: (_delta, accumulated) => {
const { text: visibleText } = extractStreamMetadata(accumulated);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId ? { ...m, content: accumulated } : m,
m.id === assistantMessageId ? { ...m, content: visibleText } : m,
),
);
},
onTextEnd: (content) => {
onTextEnd: (rawContent) => {
const { text, metadata } = extractStreamMetadata(rawContent);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? { ...m, content, isStreaming: false }
? {
...m,
content: text,
isStreaming: false,
sources: metadata?.sources,
suggestedQuestions: metadata?.suggestedQuestions,
}
: m,
),
);
@@ -409,6 +519,11 @@ function ViewerChatPanelContent({
}
};
const handleSuggestedQuestion = (question: string) => {
if (isLoading) return;
handleSubmit({ text: question, files: [] });
};
return (
<div className="flex h-full w-full flex-col border-l border-gray-200 bg-white">
{/* Header with Thread Selector and New Chat Button */}
@@ -463,28 +578,73 @@ function ViewerChatPanelContent({
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-4">
{messages.length === 0 ? (
<ConversationEmptyState
icon={
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<SparklesIcon className="size-6 text-primary" />
<ConversationEmptyState className="items-start text-left">
<div className="flex w-full flex-col items-start gap-5 px-2">
<div>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
<SparklesIcon className="size-4 text-primary" />
</div>
<h3 className="text-base font-semibold text-gray-900">
Welcome
</h3>
</div>
<p className="mt-1 text-sm text-gray-500">
Your AI Data Room Agent is ready to help.
</p>
</div>
}
title="Ask me anything"
description={`I can help you understand and analyze the content of this ${dataroomId ? "data room" : "document"}.`}
/>
<div className="w-full space-y-3">
<div className="flex items-start gap-2.5">
<SearchIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
Get answers to questions about your{" "}
{dataroomId ? "data room" : "document"} files. Open a
specific document to ask targeted questions.
</p>
</div>
<div className="flex items-start gap-2.5">
<MessageSquareTextIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
Ask clear, specific questions for more accurate results.
Multiple languages and document types are supported.
</p>
</div>
<div className="flex items-start gap-2.5">
<ShieldCheckIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
All conversations are private and confidential.
</p>
</div>
</div>
</div>
</ConversationEmptyState>
) : (
<>
{messages.map((message) => (
<div key={message.id} className="space-y-2">
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
</div>
))}
{(() => {
const lastAssistantIdx = messages.reduce(
(acc, m, i) => (m.role === "assistant" ? i : acc),
-1,
);
return messages.map((message, idx) => (
<div key={message.id} className="space-y-2">
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
sources={message.sources}
suggestedQuestions={message.suggestedQuestions}
onSuggestedQuestionClick={handleSuggestedQuestion}
isLastAssistantMessage={
message.role === "assistant" &&
idx === lastAssistantIdx &&
!isLoading
}
/>
</div>
));
})()}
{/* Show loading indicator while waiting for response */}
{isLoading && messages[messages.length - 1]?.role === "user" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
@@ -1,19 +1,58 @@
import { ItemType } from "@prisma/client";
import prisma from "@/lib/prisma";
/**
* Get filtered dataroom document IDs based on link permissions
* @param dataroomId - The dataroom ID
* @param linkId - The link ID (optional, for external visitors)
* @returns Array of accessible dataroom document IDs
* @returns Array of accessible dataroom document IDs, or undefined if unrestricted
*/
export async function getFilteredDataroomDocumentIds(
dataroomId: string,
linkId?: string,
): Promise<string[]> {
): Promise<string[] | undefined> {
const getDescendantFolderIds = (
rootFolderIds: string[],
folders: { id: string; parentId: string | null }[],
deniedFolderIds: Set<string>,
): Set<string> => {
const folderIds = new Set(rootFolderIds);
const queue = [...rootFolderIds];
// Build parent -> children map for O(1) descendant traversal
const childFoldersByParent = new Map<string, string[]>();
for (const folder of folders) {
if (!folder.parentId) continue;
const children = childFoldersByParent.get(folder.parentId) ?? [];
children.push(folder.id);
childFoldersByParent.set(folder.parentId, children);
}
while (queue.length > 0) {
const currentFolderId = queue.shift();
if (!currentFolderId) continue;
const children = childFoldersByParent.get(currentFolderId) ?? [];
for (const childFolderId of children) {
// Explicit deny on a folder should block inherited access via parent folder.
if (deniedFolderIds.has(childFolderId)) {
continue;
}
if (folderIds.has(childFolderId)) continue;
folderIds.add(childFolderId);
queue.push(childFolderId);
}
}
return folderIds;
};
try {
// If external visitor with link, check permission groups
// No link means this is an internal/team context without link-level restrictions
if (!linkId) {
return [];
return undefined;
}
const link = await prisma.link.findUnique({
@@ -28,41 +67,132 @@ export async function getFilteredDataroomDocumentIds(
return [];
}
let accessibleDocuments: { itemId: string }[] = [];
if (link.permissionGroupId) {
accessibleDocuments = await prisma.permissionGroupAccessControls.findMany(
{
where: {
groupId: link.permissionGroupId,
itemType: "DATAROOM_DOCUMENT",
canView: true,
},
select: {
itemId: true,
},
},
);
// If link has no group restrictions, treat as unrestricted dataroom access
if (!link.groupId && !link.permissionGroupId) {
return undefined;
}
if (link.groupId) {
accessibleDocuments = await prisma.viewerGroupAccessControls.findMany({
let accessControls: {
itemId: string;
itemType: ItemType;
canView: boolean;
canDownload: boolean;
}[] = [];
if (link.permissionGroupId) {
accessControls = await prisma.permissionGroupAccessControls.findMany({
where: {
groupId: link.groupId,
itemType: "DATAROOM_DOCUMENT",
canView: true,
groupId: link.permissionGroupId,
itemType: {
in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],
},
},
select: {
itemId: true,
itemType: true,
canView: true,
canDownload: true,
},
});
}
const dataroomDocumentIds = accessibleDocuments.map(
(document) => document.itemId,
if (link.groupId) {
// Legacy group permissions take precedence when present
accessControls = await prisma.viewerGroupAccessControls.findMany({
where: {
groupId: link.groupId,
itemType: {
in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],
},
},
select: {
itemId: true,
itemType: true,
canView: true,
canDownload: true,
},
});
}
const isAllowed = (control: { canView: boolean; canDownload: boolean }) =>
control.canView || control.canDownload;
const explicitlyAllowedDocumentIds = new Set(
accessControls
.filter(
(permission) =>
permission.itemType === ItemType.DATAROOM_DOCUMENT &&
isAllowed(permission),
)
.map((permission) => permission.itemId),
);
return dataroomDocumentIds;
const explicitlyDeniedDocumentIds = new Set(
accessControls
.filter(
(permission) =>
permission.itemType === ItemType.DATAROOM_DOCUMENT &&
!isAllowed(permission),
)
.map((permission) => permission.itemId),
);
const allowedFolderIds = accessControls
.filter(
(permission) =>
permission.itemType === ItemType.DATAROOM_FOLDER &&
isAllowed(permission),
)
.map((permission) => permission.itemId);
const deniedFolderIds = new Set(
accessControls
.filter(
(permission) =>
permission.itemType === ItemType.DATAROOM_FOLDER &&
!isAllowed(permission),
)
.map((permission) => permission.itemId),
);
if (allowedFolderIds.length > 0) {
const folders = await prisma.dataroomFolder.findMany({
where: { dataroomId },
select: {
id: true,
parentId: true,
},
});
// Start from explicitly allowed folder IDs and include descendants,
// while respecting explicit folder denies.
const accessibleFolderIds = Array.from(
getDescendantFolderIds(allowedFolderIds, folders, deniedFolderIds),
);
if (accessibleFolderIds.length > 0) {
const folderDocuments = await prisma.dataroomDocument.findMany({
where: {
dataroomId,
folderId: { in: accessibleFolderIds },
},
select: {
id: true,
},
});
for (const document of folderDocuments) {
explicitlyAllowedDocumentIds.add(document.id);
}
}
}
// Explicit document denies should always override inherited folder access.
for (const deniedDocumentId of explicitlyDeniedDocumentIds) {
explicitlyAllowedDocumentIds.delete(deniedDocumentId);
}
return Array.from(explicitlyAllowedDocumentIds);
} catch (error) {
console.error("Error getting filtered file IDs:", error);
throw new Error("Failed to get filtered file IDs");
+570 -38
View File
@@ -1,5 +1,5 @@
import { OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { generateText, streamText } from "ai";
import prisma from "@/lib/prisma";
@@ -7,11 +7,425 @@ interface SendMessageOptions {
chatId: string;
content: string;
vectorStoreId: string;
/** Permission-scoped dataroom document IDs; undefined means unrestricted */
filteredDataroomDocumentIds?: string[];
/** Filter file_search results to a specific document by its ID */
filterDocumentId?: string;
/** User-selected dataroom document IDs to filter file_search results */
userSelectedDataroomDocumentIds?: string[];
/** Dataroom ID for citation-to-document mapping */
dataroomId?: string;
/** Link ID used to generate canonical viewer URLs */
linkId?: string;
}
interface CitationCandidate {
fileId?: string;
filename?: string;
page?: number;
}
interface ResolvedReference {
dataroomDocumentId: string;
documentName: string;
url: string;
page?: number;
folderPath?: string;
}
export const CHAT_METADATA_PREFIX = "\n\n<!-- CHAT_METADATA:";
export const CHAT_METADATA_SUFFIX = " -->";
export interface ChatStreamSource {
id: string;
name: string;
url: string;
dataroomDocumentId?: string;
page?: number;
folderPath?: string;
}
export interface ChatStreamMetadata {
sources: ChatStreamSource[];
suggestedQuestions: string[];
}
function validatePageValue(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
const rounded = Math.round(value);
return rounded < 0 ? undefined : rounded;
}
function normalizePageNumber(value: unknown): number | undefined {
const validated = validatePageValue(value);
if (validated === undefined) return undefined;
// Treat 0 as first page for 1-based page_number / page fields.
return validated === 0 ? 1 : validated;
}
function extractPageNumberFromRecord(
record: Record<string, unknown>,
): number | undefined {
const directPage = normalizePageNumber(
record.page_number ?? record.page ?? record.pageNumber,
);
if (directPage !== undefined) {
return directPage;
}
// Some payloads expose 0-based page index.
const pageIndex = validatePageValue(record.page_index ?? record.pageIndex);
if (pageIndex !== undefined) {
return pageIndex + 1;
}
const location =
typeof record.location === "object" && record.location
? (record.location as Record<string, unknown>)
: null;
if (location) {
const locationPage = normalizePageNumber(
location.page_number ?? location.page ?? location.pageNumber,
);
if (locationPage !== undefined) {
return locationPage;
}
const locationPageIndex = validatePageValue(
location.page_index ?? location.pageIndex,
);
if (locationPageIndex !== undefined) {
return locationPageIndex + 1;
}
}
return undefined;
}
function extractFileCitations(response: unknown): CitationCandidate[] {
const citations: CitationCandidate[] = [];
const visited = new Set<object>();
const addCitation = (candidate: CitationCandidate) => {
if (!candidate.fileId && !candidate.filename) return;
citations.push(candidate);
};
const visit = (node: unknown) => {
if (!node || typeof node !== "object") return;
if (visited.has(node as object)) {
return;
}
visited.add(node as object);
const record = node as Record<string, unknown>;
// OpenAI often provides annotations with type=file_citation.
if (record.type === "file_citation") {
addCitation({
fileId:
(record.file_id as string | undefined) ||
(record.fileId as string | undefined),
filename:
(record.filename as string | undefined) ||
(record.file_name as string | undefined),
page: extractPageNumberFromRecord(record),
});
}
// Sometimes payloads are nested under file_citation key.
if (record.file_citation && typeof record.file_citation === "object") {
const nested = record.file_citation as Record<string, unknown>;
addCitation({
fileId:
(nested.file_id as string | undefined) ||
(nested.fileId as string | undefined),
filename:
(nested.filename as string | undefined) ||
(nested.file_name as string | undefined),
page: extractPageNumberFromRecord(nested),
});
visit(record.file_citation);
}
for (const value of Object.values(record)) {
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
} else {
visit(value);
}
}
};
visit(response);
return citations;
}
function stripTrailingReferencesSection(text: string): string {
return text
.replace(
/\n{2,}(?:#{1,6}\s*)?References\s*\n(?:\s*\n)?(?:[-*]\s.*(?:\n|$))+$/i,
"",
)
.trimEnd();
}
function escapeMarkdownLinkText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
}
async function resolveReferencesFromCitations({
dataroomId,
linkId,
citations,
allowedDocumentIds,
}: {
dataroomId?: string;
linkId?: string;
citations: CitationCandidate[];
/** Permission-scoped dataroom document IDs; when set, only these documents are considered */
allowedDocumentIds?: string[];
}): Promise<ResolvedReference[]> {
if (!dataroomId || !linkId || citations.length === 0) {
return [];
}
const link = await prisma.link.findUnique({
where: { id: linkId },
select: { domainId: true, domainSlug: true, slug: true },
});
const isCustomDomain = Boolean(link?.domainId && link.domainSlug && link.slug);
const viewerBasePath = isCustomDomain
? `/${link!.slug}`
: `/view/${linkId}`;
const citedFileIds = Array.from(
new Set(
citations
.map((citation) => citation.fileId)
.filter((fileId): fileId is string => Boolean(fileId)),
),
);
const fileIdToDocument = new Map<
string,
{ dataroomDocumentId: string; documentName: string; folderPath?: string }
>();
if (citedFileIds.length > 0) {
const dataroomDocuments = await prisma.dataroomDocument.findMany({
where: {
dataroomId,
...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),
document: {
versions: {
some: {
isPrimary: true,
fileId: { in: citedFileIds },
},
},
},
},
select: {
id: true,
folder: {
select: {
name: true,
path: true,
},
},
document: {
select: {
name: true,
versions: {
where: { isPrimary: true },
take: 1,
select: {
fileId: true,
},
},
},
},
},
});
for (const dataroomDocument of dataroomDocuments) {
const primaryFileId = dataroomDocument.document.versions[0]?.fileId;
if (!primaryFileId) continue;
fileIdToDocument.set(primaryFileId, {
dataroomDocumentId: dataroomDocument.id,
documentName: dataroomDocument.document.name,
folderPath: dataroomDocument.folder?.path,
});
}
}
const citedFilenames = Array.from(
new Set(
citations
.map((citation) => citation.filename)
.filter((filename): filename is string => Boolean(filename)),
),
);
const uniqueFilenameToDocument = new Map<
string,
{ dataroomDocumentId: string; documentName: string; folderPath?: string }
>();
if (citedFilenames.length > 0) {
const namedDataroomDocuments = await prisma.dataroomDocument.findMany({
where: {
dataroomId,
...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),
document: {
name: { in: citedFilenames },
},
},
select: {
id: true,
folder: {
select: {
name: true,
path: true,
},
},
document: {
select: {
name: true,
},
},
},
});
const groupedByName = new Map<
string,
{ dataroomDocumentId: string; documentName: string; folderPath?: string }[]
>();
for (const item of namedDataroomDocuments) {
const current = groupedByName.get(item.document.name) ?? [];
current.push({
dataroomDocumentId: item.id,
documentName: item.document.name,
folderPath: item.folder?.path,
});
groupedByName.set(item.document.name, current);
}
for (const [filename, docs] of groupedByName.entries()) {
if (docs.length === 1) {
uniqueFilenameToDocument.set(filename, docs[0]);
}
}
}
const references: ResolvedReference[] = [];
const seen = new Set<string>();
for (const citation of citations) {
const mappedByFileId = citation.fileId
? fileIdToDocument.get(citation.fileId)
: undefined;
const mappedByFilename = citation.filename
? uniqueFilenameToDocument.get(citation.filename)
: undefined;
const mapped = mappedByFileId ?? mappedByFilename;
if (!mapped) continue;
const dedupeKey = `${mapped.dataroomDocumentId}:${citation.page ?? ""}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
references.push({
dataroomDocumentId: mapped.dataroomDocumentId,
documentName: mapped.documentName,
url: `${viewerBasePath}/d/${mapped.dataroomDocumentId}${
citation.page ? `?p=${citation.page}` : ""
}`,
page: citation.page,
folderPath: mapped.folderPath,
});
}
return references;
}
function buildReferencesSection(references: ResolvedReference[]): string {
const lines = ["", "", "References", ""];
if (references.length === 0) {
lines.push("- None");
return lines.join("\n");
}
for (const reference of references) {
const documentLabel = escapeMarkdownLinkText(reference.documentName);
const pageSuffix = reference.page ? ` - p. ${reference.page}` : "";
lines.push(`- [${documentLabel}](${reference.url})${pageSuffix}`);
}
return lines.join("\n");
}
async function generateSuggestedQuestions(
userQuestion: string,
assistantResponse: string,
): Promise<string[]> {
try {
const { text } = await generateText({
model: openai("gpt-4o-mini"),
maxOutputTokens: 200,
messages: [
{
role: "system",
content:
"Generate exactly 3 concise follow-up questions a user might ask based on this conversation. Output only the questions, one per line. No numbering, no bullets, no prefixes.",
},
{
role: "user",
content: `User asked: "${userQuestion}"\n\nAssistant answered: "${assistantResponse.slice(0, 500)}"`,
},
],
});
return text
.split("\n")
.map((q) => q.trim())
.filter((q) => q.length > 0 && q.endsWith("?"))
.slice(0, 3);
} catch {
return [];
}
}
function buildStreamMetadataTail(
references: ResolvedReference[],
suggestedQuestions: string[],
): string {
const metadata: ChatStreamMetadata = {
sources: references.map((ref, idx) => ({
id: `D-${idx + 1}`,
name: ref.documentName,
url: ref.url,
dataroomDocumentId: ref.dataroomDocumentId,
page: ref.page,
folderPath: ref.folderPath,
})),
suggestedQuestions,
};
return `${CHAT_METADATA_PREFIX}${JSON.stringify(metadata)}${CHAT_METADATA_SUFFIX}`;
}
/**
@@ -25,6 +439,8 @@ export async function sendMessage({
filteredDataroomDocumentIds,
filterDocumentId,
userSelectedDataroomDocumentIds,
dataroomId,
linkId,
}: SendMessageOptions) {
// Get conversation history from database
const history = await prisma.chatMessage.findMany({
@@ -33,11 +449,20 @@ export async function sendMessage({
take: 10,
});
const hasPriorAssistantReply = history.some((m) => m.role === "assistant");
const reasoningEffort = "minimal";
const maxOutputTokens = hasPriorAssistantReply ? 420 : 220;
// Save user message
await prisma.chatMessage.create({
data: { chatId, role: "user", content },
});
let resolveReferencesForStream: (value: string) => void = () => {};
const referencesForStream = new Promise<string>((resolve) => {
resolveReferencesForStream = resolve;
});
// Build messages for AI SDK
const messages = [
{
@@ -45,11 +470,30 @@ export async function sendMessage({
content: `You are a helpful AI assistant answering questions about documents.
Use the file_search tool to find relevant information from the documents.
Always cite sources with document names and page numbers when providing information.
Do not include a "References" section in your answer. References are appended automatically by the system.
Keep answers concise, direct, and non-repetitive.
Start with the direct answer in the first sentence.
Default format: 1 short paragraph or up to 2 bullets.
Avoid long document lists unless the user explicitly asks for a full list.
Only mention documents that are directly needed for the user's question.
${
!hasPriorAssistantReply
? `This is the first assistant reply in the conversation.
Prioritize speed:
- Start with a direct answer in 1-2 sentences.
- Add at most 2 short bullets only if needed.
- Keep it under 80 words before references.
If deeper analysis might help, add one short sentence offering a deeper follow-up.`
: ""
}
If you cannot find the answer in the documents, say so clearly.`,
},
...history.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
content:
m.role === "assistant"
? stripTrailingReferencesSection(m.content)
: m.content,
})),
{ role: "user" as const, content },
];
@@ -59,6 +503,9 @@ If you cannot find the answer in the documents, say so clearly.`,
vectorStoreIds: [vectorStoreId],
};
const NO_ACCESS_SENTINEL = "__no_access__";
let effectiveDataroomDocumentIds: string[] | undefined;
// Add document filter when viewing a specific document in a dataroom
if (filterDocumentId) {
fileSearchOptions.filters = {
@@ -73,35 +520,39 @@ If you cannot find the answer in the documents, say so clearly.`,
// User explicitly selected documents to filter to
// Intersect with permission-based filter if it exists
let effectiveIds = userSelectedDataroomDocumentIds;
if (filteredDataroomDocumentIds && filteredDataroomDocumentIds.length > 0) {
if (filteredDataroomDocumentIds !== undefined) {
// Only include user-selected IDs that are also in the permission-filtered list
effectiveIds = userSelectedDataroomDocumentIds.filter((id) =>
filteredDataroomDocumentIds.includes(id),
);
// Security: If intersection is empty (user sent unauthorized IDs),
// fall back to the permission-based filter to prevent searching all documents
// fall back to the permission-based filter
if (effectiveIds.length === 0) {
effectiveIds = filteredDataroomDocumentIds;
}
}
if (effectiveIds.length > 0) {
effectiveDataroomDocumentIds = effectiveIds;
} else if (filteredDataroomDocumentIds !== undefined) {
// Permission-restricted dataroom chat scope
effectiveDataroomDocumentIds = filteredDataroomDocumentIds;
}
if (!filterDocumentId && effectiveDataroomDocumentIds !== undefined) {
if (effectiveDataroomDocumentIds.length > 0) {
fileSearchOptions.filters = {
type: "in",
key: "dataroomDocumentId",
value: effectiveIds,
value: effectiveDataroomDocumentIds,
};
} else {
// Keep retrieval restricted when the viewer has no accessible documents
// by applying a guaranteed-empty metadata filter.
fileSearchOptions.filters = {
type: "eq",
key: "dataroomDocumentId",
value: NO_ACCESS_SENTINEL,
};
}
} else if (
filteredDataroomDocumentIds &&
filteredDataroomDocumentIds.length > 0
) {
// Only apply filter if there are specific documents to filter to
// An empty array would filter out ALL documents
fileSearchOptions.filters = {
type: "in",
key: "dataroomDocumentId",
value: filteredDataroomDocumentIds,
};
}
const latestMessage = history.at(0);
@@ -112,6 +563,7 @@ If you cannot find the answer in the documents, say so clearly.`,
// Use AI SDK streamText with file_search tool
const result = streamText({
model: openai.responses("gpt-4o-mini"),
maxOutputTokens,
messages,
tools: {
file_search: openai.tools.fileSearch(fileSearchOptions),
@@ -119,31 +571,111 @@ If you cannot find the answer in the documents, say so clearly.`,
providerOptions: {
openai: {
previousResponseId,
} satisfies OpenAIResponsesProviderOptions,
} as OpenAIResponsesProviderOptions,
},
onFinish: async ({ text, usage, response }) => {
await Promise.all([
prisma.chatMessage.create({
data: {
chatId,
role: "assistant",
content: text,
metadata: {
usage,
vectorStoreId,
responseId: response.id,
modelId: response.modelId,
filters: fileSearchOptions.filters,
try {
const shouldAppendReferences = Boolean(dataroomId && linkId);
const citations = extractFileCitations(response);
const [resolvedReferences, suggestedQuestions] = await Promise.all([
resolveReferencesFromCitations({
dataroomId,
linkId,
citations,
allowedDocumentIds: filteredDataroomDocumentIds,
}),
generateSuggestedQuestions(content, text),
]);
const maxReferences = hasPriorAssistantReply ? 6 : 3;
const limitedReferences = resolvedReferences.slice(0, maxReferences);
const referencesSection = shouldAppendReferences
? buildReferencesSection(limitedReferences)
: "";
const strippedText = stripTrailingReferencesSection(text).trim();
const rawTrimmedText = text.trim();
const hasRawText = rawTrimmedText.length > 0;
const resolvedText =
strippedText ||
rawTrimmedText ||
"I couldn't find that in the indexed documents.";
const metadataTail = buildStreamMetadataTail(
shouldAppendReferences ? limitedReferences : [],
suggestedQuestions,
);
const streamTail = hasRawText
? metadataTail
: `${resolvedText}${metadataTail}`;
resolveReferencesForStream(streamTail);
const finalContent = shouldAppendReferences
? `${resolvedText}${referencesSection}`
: resolvedText;
await Promise.all([
prisma.chatMessage.create({
data: {
chatId,
role: "assistant",
content: finalContent,
metadata: {
usage: usage as any,
vectorStoreId,
responseId: response.id,
modelId: response.modelId,
filters: fileSearchOptions.filters as any,
...(shouldAppendReferences && {
citations: citations as any,
references: limitedReferences as any,
}),
suggestedQuestions,
reasoningEffort,
},
},
},
}),
prisma.chat.update({
where: { id: chatId },
data: { lastMessageAt: new Date() },
}),
]);
}),
prisma.chat.update({
where: { id: chatId },
data: { lastMessageAt: new Date() },
}),
]);
} catch (error) {
resolveReferencesForStream("");
console.error("Error finalizing AI response references:", error);
await Promise.all([
prisma.chatMessage.create({
data: {
chatId,
role: "assistant",
content: text,
metadata: {
usage,
vectorStoreId,
responseId: response.id,
modelId: response.modelId,
filters: fileSearchOptions.filters,
referenceError:
error instanceof Error ? error.message : "Unknown error",
},
},
}),
prisma.chat.update({
where: { id: chatId },
data: { lastMessageAt: new Date() },
}),
]);
}
},
});
return result;
return {
result,
referencesForStream,
};
}
@@ -18,38 +18,23 @@ const routeHandlers = {
// GET /api/conversations
"GET /": async (req: NextApiRequest, res: NextApiResponse) => {
// Handle listing conversations
const { dataroomId, linkId, viewerId } = req.query as {
const { dataroomId, viewerId } = req.query as {
dataroomId?: string;
linkId?: string;
viewerId?: string;
};
if (!dataroomId && !linkId) {
return res.status(400).json({ error: "Missing dataroomId or linkId" });
if (!dataroomId || !viewerId) {
return res.status(400).json({ error: "Missing dataroomId or viewerId" });
}
// For datarooms, viewerId is required
if (dataroomId && !viewerId) {
return res.status(400).json({ error: "Missing viewerId" });
}
// For document links, show all conversations (public) if no viewerId
// Otherwise show only conversations where the viewer is a participant
const conversations = await prisma.conversation.findMany({
where: {
...(dataroomId ? { dataroomId } : { linkId }),
...(viewerId
? {
participants: {
some: {
viewerId,
},
},
}
: {
// For document links without viewerId, show all conversations
// (they can be filtered by visibility mode if needed)
}),
dataroomId,
participants: {
some: {
viewerId,
},
},
},
include: {
messages: {
@@ -63,22 +48,15 @@ const routeHandlers = {
document: true,
},
},
participants: viewerId
? {
where: {
viewerId,
},
select: {
receiveNotifications: true,
},
take: 1,
}
: {
select: {
receiveNotifications: true,
},
take: 1,
},
participants: {
where: {
viewerId,
},
select: {
receiveNotifications: true,
},
take: 1,
},
},
orderBy: {
updatedAt: "desc",
@@ -88,7 +66,7 @@ const routeHandlers = {
const conversationsWithNotifications = conversations.map(
(conversation) => ({
...conversation,
receiveNotifications: conversation.participants[0]?.receiveNotifications ?? false,
receiveNotifications: conversation.participants[0].receiveNotifications,
}),
);
@@ -97,165 +75,88 @@ const routeHandlers = {
// POST /api/conversations
"POST /": async (req: NextApiRequest, res: NextApiResponse) => {
let { dataroomId, viewId, viewerId, documentId, pageNumber, linkId, ...data } =
const { dataroomId, viewId, viewerId, documentId, pageNumber, ...data } =
req.body as CreateConversationInput & {
dataroomId?: string;
dataroomId: string;
viewId: string;
viewerId?: string;
documentId?: string;
pageNumber?: number;
linkId?: string;
};
// Get team from either dataroom or link first (needed for anonymous viewer creation)
let team;
if (dataroomId) {
// Check if conversations are allowed for dataroom
const areAllowed = await conversationService.areConversationsAllowed(
dataroomId,
linkId,
);
// Check if conversations are allowed
const areAllowed = await conversationService.areConversationsAllowed(
dataroomId,
data.linkId,
);
if (!areAllowed) {
return res.status(403).json({
error: "Conversations are disabled for this dataroom or link",
});
}
team = await prisma.team.findFirst({
where: {
datarooms: {
some: {
id: dataroomId,
},
},
},
select: {
id: true,
},
});
} else if (linkId) {
// For document links, check link.enableConversation and get team from link
const link = await prisma.link.findUnique({
where: { id: linkId },
select: {
enableConversation: true,
teamId: true,
document: {
select: {
teamId: true,
},
},
},
});
if (!link || !link.enableConversation) {
return res.status(403).json({
error: "Conversations are disabled for this link",
});
}
team = await prisma.team.findUnique({
where: { id: link.teamId || link.document?.teamId },
select: {
id: true,
},
if (!areAllowed) {
return res.status(403).json({
error: "Conversations are disabled for this dataroom or link",
});
}
// Check if viewerId is provided
if (!viewerId) {
return res.status(400).json({ error: "Viewer is required" });
}
const team = await prisma.team.findFirst({
where: {
datarooms: {
some: {
id: dataroomId,
},
},
},
select: {
id: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team not found" });
}
// For document links, create anonymous viewer if viewerId is not provided
if (!viewerId && !dataroomId) {
// Get IP address from request for anonymous viewer creation
const ipAddress = req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown";
const anonymousEmail = `anonymous-${String(ipAddress).replace(/\./g, '-')}-${Date.now()}@anonymous.papermark.com`;
// Create anonymous viewer
const anonymousViewer = await prisma.viewer.create({
data: {
email: anonymousEmail,
verified: false,
teamId: team.id,
},
select: { id: true },
});
// Use the anonymous viewer ID
viewerId = anonymousViewer.id;
} else if (!viewerId && dataroomId) {
return res.status(400).json({ error: "Viewer is required" });
}
// Map documentId to dataroomDocumentId and get version info if provided
// Make sure linkId is included in enhancedData (it was extracted earlier)
let enhancedData = { ...data, linkId };
let enhancedData = { ...data };
if (documentId) {
if (dataroomId) {
// For dataroom documents
const dataroomDocument = await prisma.dataroomDocument.findFirst({
where: {
dataroomId,
documentId,
},
include: {
document: {
include: {
versions: {
where: { isPrimary: true },
select: { versionNumber: true },
},
const dataroomDocument = await prisma.dataroomDocument.findFirst({
where: {
dataroomId,
documentId,
},
include: {
document: {
include: {
versions: {
where: { isPrimary: true },
select: { versionNumber: true },
},
},
},
});
},
});
if (dataroomDocument) {
enhancedData.dataroomDocumentId = dataroomDocument.id;
if (dataroomDocument) {
enhancedData.dataroomDocumentId = dataroomDocument.id;
// Set page number if provided
if (pageNumber) {
enhancedData.documentPageNumber = pageNumber;
}
// Set document version number from the primary version
if (dataroomDocument.document.versions[0]?.versionNumber) {
enhancedData.documentVersionNumber =
dataroomDocument.document.versions[0].versionNumber;
}
// Set page number if provided
if (pageNumber) {
enhancedData.documentPageNumber = pageNumber;
}
} else {
// For document links, get version info directly from document
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
versions: {
where: { isPrimary: true },
select: { versionNumber: true },
},
},
});
if (document) {
// Set page number if provided
if (pageNumber) {
enhancedData.documentPageNumber = pageNumber;
}
// Set document version number from the primary version
if (document.versions[0]?.versionNumber) {
enhancedData.documentVersionNumber =
document.versions[0].versionNumber;
}
// Set document version number from the primary version
if (dataroomDocument.document.versions[0]?.versionNumber) {
enhancedData.documentVersionNumber =
dataroomDocument.document.versions[0].versionNumber;
}
}
}
// Create the conversation
const conversation = await conversationService.createConversation({
dataroomId: dataroomId || undefined,
dataroomId,
viewerId,
viewId,
teamId: team.id,
@@ -273,28 +174,26 @@ const routeHandlers = {
// Cancel any existing unsent notification runs for this dataroom
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
if (dataroomId) {
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId,
messageId: conversation.messages[0].id,
conversationId: conversation.id,
senderUserId: viewerId,
teamId: team.id,
},
{
idempotencyKey: `conversation-notification-${team.id}-${dataroomId}-${conversation.id}-${conversation.messages[0].id}`,
tags: [
`team_${team.id}`,
`dataroom_${dataroomId}`,
`conversation_${conversation.id}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
}
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId,
messageId: conversation.messages[0].id,
conversationId: conversation.id,
senderUserId: viewerId,
teamId: team.id,
},
{
idempotencyKey: `conversation-notification-${team.id}-${dataroomId}-${conversation.id}-${conversation.messages[0].id}`,
tags: [
`team_${team.id}`,
`dataroom_${dataroomId}`,
`conversation_${conversation.id}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
return res.status(201).json(conversation);
},
@@ -320,7 +219,7 @@ const routeHandlers = {
viewerId,
});
// Get all delayed and queued runs for this conversation
// Get all delayed and queued runs for this dataroom
const allRuns = await runs.list({
taskIdentifier: ["send-conversation-team-member-notification"],
tag: [`conversation_${message.conversationId}`],
@@ -328,31 +227,29 @@ const routeHandlers = {
period: "5m",
});
// Cancel any existing unsent notification runs for this conversation
// Cancel any existing unsent notification runs for this dataroom
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
if (message.conversation.dataroomId) {
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId: message.conversation.dataroomId,
messageId: message.id,
conversationId: message.conversationId,
senderUserId: viewerId,
teamId: message.conversation.teamId,
},
{
idempotencyKey: `conversation-notification-${message.conversation.teamId}-${message.conversation.dataroomId}-${message.conversationId}-${message.id}`,
tags: [
`team_${message.conversation.teamId}`,
`dataroom_${message.conversation.dataroomId}`,
`conversation_${message.conversationId}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
}
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId: message.conversation.dataroomId,
messageId: message.id,
conversationId: message.conversationId,
senderUserId: viewerId,
teamId: message.conversation.teamId,
},
{
idempotencyKey: `conversation-notification-${message.conversation.teamId}-${message.conversation.dataroomId}-${message.conversationId}-${message.id}`,
tags: [
`team_${message.conversation.teamId}`,
`dataroom_${message.conversation.dataroomId}`,
`conversation_${message.conversationId}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
return res.status(201).json(message);
},
@@ -564,6 +564,12 @@ const routeHandlers = {
return res.status(201).json(message);
} catch (error) {
console.error("Error adding message:", error);
if (
error instanceof Error &&
error.message.startsWith("Content cannot be")
) {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: "Internal server error" });
}
},
@@ -17,11 +17,9 @@ export function ConversationListItem({
}) {
// Helper function to format document reference
const formatDocumentReference = () => {
// Support both dataroom and document conversations
const documentName =
conversation.dataroomDocumentName || conversation.documentName;
if (!documentName) return "Untitled conversation";
if (!conversation.dataroomDocumentName) return "Untitled conversation";
const documentName = conversation.dataroomDocumentName;
const parts = [];
if (conversation.documentPageNumber) {
@@ -1,7 +1,5 @@
import { useEffect, useState } from "react";
import { LinkType } from "@prisma/client";
import { DEFAULT_LINK_TYPE } from "@/components/links/link-sheet";
import LinkItem from "@/components/links/link-sheet/link-item";
import { LinkUpgradeOptions } from "@/components/links/link-sheet/link-options";
@@ -11,7 +9,6 @@ export default function ConversationSection({
setData,
isAllowed,
handleUpgradeStateChange,
linkType,
}: {
data: DEFAULT_LINK_TYPE;
setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;
@@ -21,7 +18,6 @@ export default function ConversationSection({
trigger,
plan,
}: LinkUpgradeOptions) => void;
linkType?: Omit<LinkType, "WORKFLOW_LINK">;
}) {
const { enableConversation } = data;
const [enabled, setEnabled] = useState<boolean>(true);
@@ -50,25 +46,20 @@ export default function ConversationSection({
setEnabled(updatedEnableConversation);
};
const isDocumentLink = linkType === LinkType.DOCUMENT_LINK;
const tooltipContent = isDocumentLink
? "Private conversations between you and your viewers related to this document."
: "Private conversations between you and your viewers related to this dataroom.";
return (
<div className="pb-5">
<LinkItem
title="Enable Q&A Conversations"
tooltipContent={tooltipContent}
tooltipContent="Private conversations between you and your viewers related to this dataroom."
enabled={enabled}
action={handleEnableConversation}
isAllowed={isAllowed}
requiredPlan={isDocumentLink ? "Pro" : "data rooms plus"}
requiredPlan="data rooms plus"
upgradeAction={() =>
handleUpgradeStateChange({
state: true,
trigger: "link_sheet_conversation_section",
plan: isDocumentLink ? "Pro" : "Data Rooms Plus",
plan: "Data Rooms Plus",
})
}
/>
@@ -29,7 +29,7 @@ export function ConversationMessage({
return (
<div className="group relative">
<div
className={`flex w-max max-w-[80%] cursor-pointer flex-col rounded-lg px-4 py-2 transition-all ${
className={`flex w-fit max-w-[80%] cursor-pointer flex-col rounded-lg px-4 py-2 transition-all ${
isAuthor ? "ml-auto bg-primary text-primary-foreground" : "bg-muted"
} ${
isSelected
@@ -41,7 +41,9 @@ export function ConversationMessage({
onClick={() => canBeSelected && onSelect?.(message.id, isVisitor)}
>
<div className="flex items-start justify-between gap-2">
<p className="flex-1 text-sm">{message.content}</p>
<p className="min-w-0 flex-1 break-words text-sm">
{message.content}
</p>
<div className="mt-0.5 flex items-center gap-1">
{isPublished && (
<div className="flex items-center text-xs opacity-60">
@@ -11,6 +11,7 @@ import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import { fetcher } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -81,18 +82,12 @@ export function ConversationViewSidebar({
const [newMessage, setNewMessage] = useState("");
// SWR hook for fetching conversations
// For document links, we can fetch without viewerId (shows all conversations)
// For datarooms, viewerId is required
const {
data: conversations = [],
error,
isLoading,
} = useSWR<Conversation[]>(
dataroomId
? (viewerId
? `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`
: null)
: `/api/conversations?linkId=${linkId}${viewerId ? `&viewerId=${viewerId}` : ""}`,
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
fetcher,
{
revalidateOnFocus: true,
@@ -109,7 +104,6 @@ export function ConversationViewSidebar({
const handleCreateConversation = async (data: CreateConversationData) => {
console.log("Creating conversation", data);
try {
// For document links without viewerId, the API will create an anonymous viewer
const response = await fetch("/api/conversations", {
method: "POST",
headers: {
@@ -118,11 +112,11 @@ export function ConversationViewSidebar({
body: JSON.stringify({
...data,
viewId,
...(viewerId && { viewerId }),
viewerId,
documentId,
pageNumber,
linkId,
...(dataroomId && { dataroomId }),
dataroomId,
}),
});
@@ -134,20 +128,9 @@ export function ConversationViewSidebar({
const newConversation = await response.json();
// If we didn't have a viewerId before, update it from the response
// (the API may have created an anonymous viewer)
if (!viewerId && newConversation.participants?.[0]?.viewerId) {
// Update viewerId for future requests
// Note: This is a workaround - ideally we'd update the parent component's state
}
// Update the SWR cache with the new conversation
const effectiveViewerId = viewerId || newConversation.participants?.[0]?.viewerId;
const cacheKey = dataroomId
? `/api/conversations?dataroomId=${dataroomId}&viewerId=${effectiveViewerId}`
: `/api/conversations?linkId=${linkId}&viewerId=${effectiveViewerId}`;
mutate(
cacheKey,
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
[newConversation, ...(conversations || [])],
false,
);
@@ -190,9 +173,7 @@ export function ConversationViewSidebar({
// Update the SWR cache with the new message
mutate(
dataroomId
? `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`
: `/api/conversations?linkId=${linkId}&viewerId=${viewerId}`,
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
conversations?.map((conv) =>
conv.id === activeConversation.id
? {
@@ -362,6 +343,7 @@ export function ConversationViewSidebar({
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
maxLength={MAX_MESSAGE_LENGTH}
className="flex-1 rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Type your message..."
/>
@@ -402,6 +384,7 @@ export function ConversationViewSidebar({
id="message"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
maxLength={MAX_MESSAGE_LENGTH}
placeholder="Type your question..."
className="min-h-[100px] w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
required
@@ -203,12 +203,12 @@ export function FAQSection({
value={faq.id}
className="rounded-md border border-gray-200 bg-white"
>
<AccordionTrigger className="px-3 py-2.5 hover:no-underline">
<AccordionTrigger className="min-w-0 px-3 py-2.5 hover:no-underline">
<div className="flex w-full items-start justify-between gap-2">
<span className="flex-shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground">
Q
</span>
<div className="line-clamp-2 min-w-0 flex-1 text-left text-sm font-medium text-gray-900">
<div className="line-clamp-2 min-w-0 flex-1 break-words text-left text-sm font-medium text-gray-900">
{faq.editedQuestion}
</div>
</div>
@@ -220,7 +220,7 @@ export function FAQSection({
<span className="flex-shrink-0 rounded bg-primary/80 px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
A
</span>
<p className="whitespace-pre-wrap text-sm font-medium text-gray-700">
<p className="min-w-0 break-words whitespace-pre-wrap text-sm font-medium text-gray-700">
{faq.answer}
</p>
</div>
@@ -51,7 +51,7 @@ export const conversationService = {
viewerId,
userId,
}: {
dataroomId?: string;
dataroomId: string;
viewId: string;
data: CreateConversationInput;
teamId: string;
@@ -70,7 +70,7 @@ export const conversationService = {
return prisma.conversation.create({
data: {
title: data.title || sanitizedInitialMessage.slice(0, 20),
...(dataroomId ? { dataroomId } : {}),
dataroomId,
teamId,
dataroomDocumentId: data.dataroomDocumentId,
documentPageNumber: data.documentPageNumber,
@@ -108,7 +108,7 @@ export const conversationService = {
participants: true,
messages: true,
views: true,
...(dataroomId ? { dataroom: true } : {}),
dataroom: true,
dataroomDocument: {
include: {
document: true,
@@ -24,6 +24,7 @@ import z from "zod";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { CustomUser } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
import { DataroomHeader } from "@/components/datarooms/dataroom-header";
import { DataroomNavigation } from "@/components/datarooms/dataroom-navigation";
@@ -334,7 +335,10 @@ export default function ConversationDetailPage() {
},
);
if (!response.ok) throw new Error("Failed to send message");
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to send message");
}
// Revalidate both the conversation and the summaries
mutate(
@@ -347,7 +351,9 @@ export default function ConversationDetailPage() {
toast.success("Message sent");
} catch (error) {
console.error("Error sending message:", error);
toast.error("Failed to send message");
toast.error(
error instanceof Error ? error.message : "Failed to send message",
);
}
};
@@ -705,10 +711,11 @@ function ConversationReplyForm({
}) {
const [newMessage, setNewMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const isOverLimit = newMessage.length > MAX_MESSAGE_LENGTH;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
if (!newMessage.trim() || isOverLimit) return;
setIsSending(true);
try {
@@ -724,15 +731,20 @@ function ConversationReplyForm({
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Textarea
placeholder="Type your reply..."
className="min-h-[100px]"
className={`min-h-[100px] ${isOverLimit ? "border-destructive focus-visible:ring-destructive" : ""}`}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<div className="flex justify-end">
<div className="flex items-center justify-between">
<span
className={`text-xs ${isOverLimit ? "font-medium text-destructive" : "text-muted-foreground"}`}
>
{newMessage.length > 0 &&
`${newMessage.length} / ${MAX_MESSAGE_LENGTH}`}
</span>
<Button
type="submit"
disabled={!newMessage.trim() || isSending}
className="ml-auto"
disabled={!newMessage.trim() || isOverLimit || isSending}
>
{isSending ? (
<>
@@ -1,545 +0,0 @@
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { ConversationListItem } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import { ConversationMessage } from "@/ee/features/conversations/components/shared/conversation-message";
import {
Loader2,
MessageSquare,
SearchIcon,
Send,
Trash2,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import z from "zod";
import { useDocument } from "@/lib/swr/use-document";
import { useTeam } from "@/context/team-context";
import { CustomUser } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import DocumentHeader from "@/components/documents/document-header";
import { NavMenu } from "@/components/navigation-menu";
import AppLayout from "@/components/layouts/app";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
// Types for conversation
interface Message {
id: string;
content: string;
createdAt: string;
updatedAt: string;
userId: string | null;
viewerId: string | null;
isRead: boolean;
}
interface Conversation {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
participants: { id: string; email: string | null }[];
documentPageNumber: number | null;
documentVersionNumber: number | null;
unreadCount: number;
messages: Message[];
documentName?: string;
}
interface ConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
viewerId: string | null;
viewerEmail?: string;
documentPageNumber: number | null;
documentVersionNumber: number | null;
unreadCount: number;
lastMessage?: {
content: string;
createdAt: string;
};
documentName?: string;
}
export default function DocumentConversationDetailPage() {
const router = useRouter();
const { document, primaryVersion } = useDocument();
const { data: session } = useSession();
const { currentTeamId: teamId } = useTeam();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { conversationId, id: documentId } = router.query;
const [searchQuery, setSearchQuery] = useState("");
// Mark messages as read
const markMessagesAsRead = async (conversationId: string) => {
if (!documentId || !teamId) return;
try {
const conversationIdParsed = z.string().cuid().parse(conversationId);
const documentIdParsed = z.string().cuid().parse(documentId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationIdParsed}/read`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) throw new Error("Failed to mark messages as read");
// Revalidate both the conversation and the summaries
mutate(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationIdParsed}`,
);
mutate(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations`,
);
} catch (error) {
console.error("Error marking messages as read:", error);
}
};
// SWR hook for fetching the conversation with messages
const { data: conversation, isLoading } = useSWR<Conversation>(
conversationId && documentId && teamId
? `/api/teams/${teamId}/documents/${documentId}/conversations/${conversationId}`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 3000, // 3 seconds
keepPreviousData: true,
onSuccess: (data) => {
if (
data &&
data.messages.some((msg) => !msg.isRead && msg.viewerId !== null)
) {
markMessagesAsRead(data.id);
}
},
onError: (err) => {
console.error("Error fetching conversation:", err);
toast.error("Failed to load conversation");
},
},
);
// SWR hook for fetching all conversations
const { data: conversations = [], isLoading: isLoadingConversations } =
useSWR<ConversationSummary[]>(
documentId && teamId
? `/api/teams/${teamId}/documents/${documentId}/conversations`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 5000, // 5 seconds
keepPreviousData: true,
onError: (err) => {
console.error("Error fetching conversations:", err);
toast.error("Failed to load conversations");
},
},
);
// Calculate total unread count across all conversations
const unreadCount = conversations.reduce(
(total, conv) => total + (conv.unreadCount || 0),
0,
);
// Only show menu if there are conversations
const hasConversations = conversations.length > 0;
// Filter conversations based on search query
const filteredConversations = conversations.filter((conv) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
// Search by viewer email
if (conv.viewerEmail?.toLowerCase().includes(query)) return true;
// Search in conversation titles and last messages
return (
conv.title?.toLowerCase().includes(query) ||
conv.lastMessage?.content.toLowerCase().includes(query) ||
conv.documentName?.toLowerCase().includes(query)
);
});
// Handle conversation deletion
const handleDeleteConversation = async () => {
if (!conversation || !documentId || !teamId) return;
setIsDeleting(true);
try {
const conversationIdParsed = z.string().cuid().parse(conversation.id);
const documentIdParsed = z.string().cuid().parse(documentId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationIdParsed}`,
{
method: "DELETE",
},
);
if (!response.ok) throw new Error("Failed to delete conversation");
// Revalidate the summaries
mutate(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations`,
);
// Navigate back to conversations list
router.push(`/documents/${documentIdParsed}/conversations`);
toast.success("Conversation deleted successfully");
} catch (error) {
console.error("Error deleting conversation:", error);
toast.error("Failed to delete conversation");
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
}
};
// Handle sending a new message
const handleSendMessage = async (e: React.FormEvent, newMessage: string) => {
e.preventDefault();
if (!newMessage.trim() || !conversation || !documentId || !teamId) return;
try {
const conversationIdParsed = z.string().cuid().parse(conversation.id);
const documentIdParsed = z.string().cuid().parse(documentId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationIdParsed}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: newMessage,
}),
},
);
if (!response.ok) throw new Error("Failed to send message");
// Revalidate both the conversation and the summaries
mutate(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationIdParsed}`,
);
mutate(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations`,
);
toast.success("Message sent");
} catch (error) {
console.error("Error sending message:", error);
toast.error("Failed to send message");
}
};
// Navigate to a different conversation
const navigateToConversation = (id: string) => {
router.push(`/documents/${documentId}/conversations/${id}`);
};
if (!document || !primaryVersion) {
return (
<AppLayout>
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<header>
<DocumentHeader
primaryVersion={primaryVersion}
prismaDocument={document}
teamId={teamId!}
/>
{hasConversations && (
<NavMenu
navigation={[
{
label: "Overview",
href: `/documents/${document.id}`,
segment: `${document.id}`,
},
{
label: "Conversations",
href: `/documents/${document.id}/conversations`,
segment: "conversations",
count: unreadCount > 0 ? unreadCount : undefined,
},
]}
/>
)}
</header>
<div className="h-[calc(100vh-20rem)] overflow-hidden rounded-md border">
<div className="flex h-full flex-col md:flex-row">
{/* Sidebar with conversations list */}
<div className="flex h-full w-full flex-col border-r md:w-96">
<div className="flex items-center p-4">
<div className="relative w-full">
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search conversations..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex h-[calc(100%-7.5rem)] flex-col">
<div className="m-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="flex flex-col gap-2 p-4 pt-0">
{isLoadingConversations ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : filteredConversations.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<p className="text-sm text-muted-foreground">
No conversations found
</p>
</div>
) : (
// Sort by most recent first
[...filteredConversations]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime(),
)
.map((conv) => (
<ConversationListItem
key={conv.id}
navigateToConversation={navigateToConversation}
conversation={conv}
isActive={conv.id === conversationId}
/>
))
)}
</div>
</ScrollArea>
</div>
</div>
</div>
{/* Conversation content */}
<div className="flex h-full flex-1 flex-col">
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : conversation ? (
<>
{/* Conversation header */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src="" />
<AvatarFallback>
{conversation.participants[0].email
? conversation.participants[0].email
.charAt(0)
.toUpperCase()
: "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h2 className="text-base font-semibold">
{conversation.title || "Conversation"}
</h2>
<p className="text-sm text-muted-foreground">
{conversation.participants[0].email ||
"Anonymous Viewer"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
title="Delete conversation"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-4 p-4">
{conversation.documentPageNumber && (
<div className="rounded-lg border bg-muted p-3 text-sm">
<p className="font-medium">
Page {conversation.documentPageNumber}
{conversation.documentVersionNumber &&
` • Version ${conversation.documentVersionNumber}`}
</p>
</div>
)}
{conversation.messages.map((message) => (
<ConversationMessage
key={message.id}
message={message}
isAuthor={
message.userId ===
(session?.user as CustomUser).id
}
senderEmail={
conversation.participants[0].email || "Viewer"
}
/>
))}
</div>
</ScrollArea>
<ConversationReplyForm
onSendMessage={handleSendMessage}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">
Conversation not found
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this conversation? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConversation}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout>
);
}
// Extracted reply form component
function ConversationReplyForm({
onSendMessage,
}: {
onSendMessage: (e: React.FormEvent, message: string) => Promise<void>;
}) {
const [newMessage, setNewMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
setIsSending(true);
try {
await onSendMessage(e, newMessage);
setNewMessage("");
} finally {
setIsSending(false);
}
};
return (
<div className="border-t p-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Textarea
placeholder="Type your reply..."
className="min-h-[100px]"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={!newMessage.trim() || isSending}
className="ml-auto"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send
</>
)}
</Button>
</div>
</form>
</div>
);
}
@@ -1,317 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { ConversationListItem } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import {
BookOpenCheckIcon,
Loader2,
MessageSquare,
Search,
} from "lucide-react";
import { toast } from "sonner";
import useSWR from "swr";
import z from "zod";
import { useDocument } from "@/lib/swr/use-document";
import { fetcher } from "@/lib/utils";
import DocumentHeader from "@/components/documents/document-header";
import { NavMenu } from "@/components/navigation-menu";
import AppLayout from "@/components/layouts/app";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface ConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
viewerId: string | null;
viewerEmail?: string;
documentPageNumber: number | null;
documentVersionNumber: number | null;
unreadCount: number;
lastMessage?: {
content: string;
createdAt: string;
};
documentName?: string;
}
export default function DocumentConversationOverviewPage() {
const router = useRouter();
const { document, primaryVersion } = useDocument();
const { currentTeamId: teamId } = useTeam();
const [searchQuery, setSearchQuery] = useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<
string | null
>(null);
const [activeTab, setActiveTab] = useState("conversations");
const documentId = router.query.id as string;
// SWR hook for fetching conversation summaries for document
const { data: conversations = [], isLoading: isLoadingConversations } =
useSWR<ConversationSummary[]>(
documentId && teamId
? `/api/teams/${teamId}/documents/${documentId}/conversations`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 10000,
keepPreviousData: true,
onError: (err) => {
console.error("Error fetching conversations:", err);
toast.error("Failed to load conversations");
},
},
);
// Calculate total unread count across all conversations
const unreadCount = conversations.reduce(
(total, conv) => total + (conv.unreadCount || 0),
0,
);
// Only show menu if there are conversations
const hasConversations = conversations.length > 0;
// Filter conversations based on search query
const filteredConversations = conversations.filter((conversation) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
// Search by viewer email
if (conversation.viewerEmail?.toLowerCase().includes(query)) return true;
// Search in conversation titles and last messages
return (
conversation.title?.toLowerCase().includes(query) ||
conversation.lastMessage?.content.toLowerCase().includes(query) ||
conversation.documentName?.toLowerCase().includes(query)
);
});
// Handle conversation deletion
const handleDeleteConversation = async () => {
if (!conversationToDelete || !documentId || !teamId) return;
setIsDeleting(true);
try {
const teamIdParsed = z.string().cuid().parse(teamId);
const documentIdParsed = z.string().cuid().parse(documentId);
const conversationToDeleteParsed = z
.string()
.cuid()
.parse(conversationToDelete);
const response = await fetch(
`/api/teams/${teamIdParsed}/documents/${documentIdParsed}/conversations/${conversationToDeleteParsed}`,
{
method: "DELETE",
},
);
if (!response.ok) throw new Error("Failed to delete conversation");
toast.success("Conversation deleted successfully");
} catch (error) {
console.error("Error deleting conversation:", error);
toast.error("Failed to delete conversation");
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
setConversationToDelete(null);
}
};
// Navigate to conversation detail
const navigateToConversation = (conversationId: string) => {
router.push(`/documents/${documentId}/conversations/${conversationId}`);
};
if (!document || !primaryVersion) {
return (
<AppLayout>
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<header>
<DocumentHeader
primaryVersion={primaryVersion}
prismaDocument={document}
teamId={teamId!}
/>
{hasConversations && (
<NavMenu
navigation={[
{
label: "Overview",
href: `/documents/${document.id}`,
segment: `${document.id}`,
},
{
label: "Conversations",
href: `/documents/${document.id}/conversations`,
segment: "conversations",
count: unreadCount > 0 ? unreadCount : undefined,
},
]}
/>
)}
</header>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="space-y-6"
>
<TabsList>
<TabsTrigger
value="conversations"
className="flex items-center gap-2"
>
<MessageSquare className="h-4 w-4" />
Conversations
<Badge variant="notification">{conversations.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="conversations" className="space-y-0">
<div className="h-[calc(100vh-20rem)] overflow-hidden rounded-md border">
<div className="flex h-full flex-col md:flex-row">
{/* Sidebar with conversations list */}
<div className="flex h-full w-full flex-col border-r md:w-96">
<div className="flex items-center p-4">
<div className="relative w-full">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search conversations..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex h-[calc(100%-7.5rem)] flex-col">
<div className="m-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="flex flex-col gap-2 p-4 pt-0">
{isLoadingConversations ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : filteredConversations.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<p className="text-sm text-muted-foreground">
No conversations found
</p>
</div>
) : (
// Sort by most recent first
[...filteredConversations]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime(),
)
.map((conversation) => (
<ConversationListItem
key={conversation.id}
navigateToConversation={
navigateToConversation
}
conversation={conversation}
isActive={false}
/>
))
)}
</div>
</ScrollArea>
</div>
</div>
</div>
{/* Empty state for the right panel */}
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="text-center">
<MessageSquare className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
Select a conversation
</p>
<p className="text-xs text-muted-foreground">
Choose a conversation to view and reply
</p>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this conversation? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConversation}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout>
);
}
@@ -6,10 +6,10 @@ import {
} from "@/ee/features/templates/constants/dataroom-templates";
import { applyTemplateSchema } from "@/ee/features/templates/schemas/dataroom-templates";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import slugify from "@sindresorhus/slugify";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { safeSlugify } from "@/lib/utils";
import { CustomUser } from "@/lib/types";
export default async function handle(
@@ -81,7 +81,7 @@ export default async function handle(
parentId: string | null = null,
): Promise<void> => {
for (const folder of folders) {
const folderPath = parentPath + "/" + slugify(folder.name);
const folderPath = parentPath + "/" + safeSlugify(folder.name);
// Create the folder
const createdFolder = await tx.dataroomFolder.create({
@@ -8,10 +8,10 @@ import {
import { generateDataroomSchema } from "@/ee/features/templates/schemas/dataroom-templates";
import { getLimits } from "@/ee/limits/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import slugify from "@sindresorhus/slugify";
import { getServerSession } from "next-auth/next";
import { newId } from "@/lib/id-helper";
import { safeSlugify } from "@/lib/utils";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
@@ -127,7 +127,7 @@ export default async function handle(
parentId: string | null = null,
): Promise<void> => {
for (const folder of folders) {
const folderPath = parentPath + "/" + slugify(folder.name);
const folderPath = parentPath + "/" + safeSlugify(folder.name);
// Create the folder
const createdFolder = await tx.dataroomFolder.create({
+1 -16
View File
@@ -117,21 +117,9 @@ export async function getLimits({
}),
};
} else {
// For conversationsInDataroom, preserve default value if parsedData has null/undefined
// Only override if explicitly set to a boolean value
const conversationsInDataroom =
parsedData.conversationsInDataroom !== null &&
parsedData.conversationsInDataroom !== undefined
? parsedData.conversationsInDataroom
: (defaultLimits as any).conversationsInDataroom;
return {
...defaultLimits,
...parsedData,
// Preserve plan default for conversationsInDataroom if not explicitly set
...(conversationsInDataroom !== undefined && {
conversationsInDataroom,
}),
// if account is paid, but link and document limits are not set, then set them to Infinity
links: parsedData.links === 50 ? Infinity : parsedData.links,
documents:
@@ -146,10 +134,7 @@ export async function getLimits({
const defaultLimits = planLimitsMap[basePlan] || FREE_PLAN_LIMITS;
return {
...defaultLimits,
// Preserve conversationsInDataroom from default limits if it exists
...((defaultLimits as any).conversationsInDataroom === undefined && {
conversationsInDataroom: false,
}),
conversationsInDataroom: false,
usage: { documents: documentCount, links: linkCount, users: userCount },
...(isTrial && {
users: 3,
@@ -4,6 +4,7 @@ import { FREE_PLAN_LIMITS } from "@/ee/limits/constants";
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { clearTeamDomainRedirects } from "@/lib/api/domains/clear-team-redirects";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
@@ -32,7 +33,6 @@ export async function customerSubscriptionDeleted(
endsAt: null,
startsAt: null,
limits: freePlanLimits,
// Clear cancellation and pause state when downgrading to free
cancelledAt: null,
pausedAt: null,
pauseStartsAt: null,
@@ -41,6 +41,8 @@ export async function customerSubscriptionDeleted(
select: { id: true },
});
await clearTeamDomainRedirects(team.id);
await log({
message: ":cry: Team *`" + team.id + "`* deleted their subscription",
type: "info",
@@ -9,6 +9,8 @@ import {
} from "@/ee/limits/constants";
import Stripe from "stripe";
import { clearTeamDomainRedirects } from "@/lib/api/domains/clear-team-redirects";
import { planSupportsRedirects } from "@/lib/api/domains/redis";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
@@ -98,6 +100,10 @@ export async function customerSubsciptionUpdated(
limits: planLimits,
},
});
if (!planSupportsRedirects(newPlan)) {
await clearTeamDomainRedirects(team.id);
}
}
// If new account, and the plan is the same, but the quantity is different, update the quantity
+3 -1
View File
@@ -88,7 +88,9 @@ export const processDocument = async ({
const keywords = await get("keywords");
if (Array.isArray(keywords) && keywords.length > 0) {
const matchedKeyword = keywords.find(
(keyword) => typeof keyword === "string" && key.includes(keyword),
(keyword) =>
typeof keyword === "string" &&
key.toLowerCase().includes(keyword.toLowerCase()),
);
if (matchedKeyword) {
+27
View File
@@ -0,0 +1,27 @@
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { getRedisKey } from "./redis";
/**
* Clears all redirect URLs for every domain belonging to a team,
* removing them from both Postgres and Redis.
*/
export async function clearTeamDomainRedirects(
teamId: string,
): Promise<void> {
const domains = await prisma.domain.findMany({
where: { teamId, redirectUrl: { not: null } },
select: { slug: true },
});
if (domains.length === 0) return;
await Promise.all([
prisma.domain.updateMany({
where: { teamId, redirectUrl: { not: null } },
data: { redirectUrl: null },
}),
...domains.map((d) => redis.del(getRedisKey(d.slug))),
]);
}
+41
View File
@@ -0,0 +1,41 @@
import { redis } from "@/lib/redis";
const DOMAIN_REDIRECT_PREFIX = "domain:redirect";
const PLANS_WITH_REDIRECTS = new Set([
"business",
"datarooms",
"datarooms-plus",
"datarooms-premium",
]);
export function planSupportsRedirects(plan: string): boolean {
const normalized = plan.replace("+old", "");
return PLANS_WITH_REDIRECTS.has(normalized);
}
export function getRedisKey(domain: string): string {
return `${DOMAIN_REDIRECT_PREFIX}:${domain.toLowerCase()}`;
}
export async function getDomainRedirectUrl(
domain: string,
): Promise<string | null> {
return redis.get<string>(getRedisKey(domain));
}
export async function setDomainRedirectUrl(
domain: string,
redirectUrl: string | null,
): Promise<void> {
const key = getRedisKey(domain);
if (redirectUrl) {
await redis.set(key, redirectUrl);
} else {
await redis.del(key);
}
}
export async function deleteDomainRedirectUrl(domain: string): Promise<void> {
await redis.del(getRedisKey(domain));
}
+66
View File
@@ -0,0 +1,66 @@
import { get } from "@vercel/edge-config";
import { isTrustedTeam } from "@/lib/edge-config/trusted-teams";
import { log } from "@/lib/utils";
import { validateUrlSecurity } from "@/lib/zod/url-validation";
type ValidationResult =
| { valid: true; url: string }
| { valid: false; message: string };
export async function validateRedirectUrl(
redirectUrl: string,
teamId: string,
): Promise<ValidationResult> {
const trimmed = redirectUrl.trim();
if (!trimmed) {
return { valid: true, url: "" };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { valid: false, message: "Invalid redirect URL" };
}
if (parsed.protocol !== "https:") {
return { valid: false, message: "Redirect URL must use HTTPS" };
}
if (!validateUrlSecurity(trimmed)) {
return {
valid: false,
message: "Redirect URL targets a disallowed resource",
};
}
const trusted = await isTrustedTeam(teamId);
if (!trusted) {
try {
const keywords = await get("keywords");
if (Array.isArray(keywords) && keywords.length > 0) {
const matchedKeyword = keywords.find(
(keyword) =>
typeof keyword === "string" &&
trimmed.toLowerCase().includes(keyword.toLowerCase()),
);
if (matchedKeyword) {
log({
message: `Redirect URL blocked: ${matchedKeyword} \n\n \`Metadata: {teamId: ${teamId}, url: ${trimmed}}\``,
type: "error",
mention: true,
});
return { valid: false, message: "This URL is not allowed" };
}
}
} catch {
// Edge config unavailable; allow the URL through
}
}
// Return the sanitized URL (trimmed, with resolved origin)
return { valid: true, url: parsed.toString() };
}
-2
View File
@@ -488,8 +488,6 @@ export async function fetchDocumentLinkData({
const linkData = await prisma.link.findUnique({
where: { id: linkId, teamId, deletedAt: null },
select: {
id: true,
enableConversation: true,
document: {
select: {
id: true,
+1 -1
View File
@@ -57,7 +57,7 @@ export const BLOCKED_PATHNAMES = [
];
// list of paths that should be excluded from team checks
export const EXCLUDED_PATHS = ["/", "/register", "/privacy", "/view"];
export const EXCLUDED_PATHS = ["/", "/register", "/privacy", "/view", "/notification-preferences"];
// free limits
export const LIMITS = {
+3 -3
View File
@@ -1,4 +1,4 @@
import slugify from "@sindresorhus/slugify";
import { safeSlugify } from "@/lib/utils";
export interface FolderInput {
id: string;
@@ -28,7 +28,7 @@ export function buildFolderPathsFromHierarchy(
// Prevent infinite loops from circular parentId references
if (visited.has(folderId)) {
const folder = folderById.get(folderId);
const fallbackPath = `/${slugify(folder?.name ?? folderId)}`;
const fallbackPath = `/${safeSlugify(folder?.name ?? folderId)}`;
pathCache.set(folderId, fallbackPath);
return fallbackPath;
}
@@ -42,7 +42,7 @@ export function buildFolderPathsFromHierarchy(
parentPath = computePath(folder.parentId, visited);
}
const path = `${parentPath}/${slugify(folder.name)}`;
const path = `${parentPath}/${safeSlugify(folder.name)}`;
pathCache.set(folderId, path);
return path;
}
+132
View File
@@ -0,0 +1,132 @@
import prisma from "@/lib/prisma";
import {
DigestBatch,
popDigestQueue,
} from "@/lib/redis/dataroom-notification-queue";
import { log } from "@/lib/utils";
import { generateUnsubscribeUrl } from "@/lib/utils/unsubscribe";
import { sendDataroomDigestNotification } from "./send-dataroom-digest-notification";
export async function processDataroomDigest(frequency: "daily" | "weekly") {
const batches = await popDigestQueue(frequency);
if (batches.length === 0) {
return { processed: 0 };
}
let processed = 0;
for (const batch of batches) {
try {
await processBatch(batch, frequency);
processed++;
} catch (error) {
await log({
message: `Failed to process ${frequency} digest for viewer ${batch.viewerId} in dataroom ${batch.dataroomId}. Error: ${(error as Error).message}`,
type: "error",
mention: true,
});
}
}
return { processed };
}
async function processBatch(batch: DigestBatch, frequency: "daily" | "weekly") {
if (batch.items.length === 0) return;
const [viewer, dataroom, senderUser] = await Promise.all([
prisma.viewer.findUnique({
where: { id: batch.viewerId, teamId: batch.teamId },
select: {
email: true,
views: {
where: {
dataroomId: batch.dataroomId,
viewType: "DATAROOM_VIEW",
verified: true,
},
orderBy: { viewedAt: "desc" },
take: 1,
include: {
link: {
select: {
id: true,
slug: true,
domainSlug: true,
domainId: true,
},
},
},
},
},
}),
prisma.dataroom.findUnique({
where: { id: batch.dataroomId, teamId: batch.teamId },
select: { name: true },
}),
batch.items[0]?.senderUserId
? prisma.user.findUnique({
where: { id: batch.items[0].senderUserId },
select: { email: true },
})
: null,
]);
if (!viewer?.email) return;
const uniqueDocIds = [
...new Set(batch.items.map((item) => item.dataroomDocumentId)),
];
const dataroomDocuments = await prisma.dataroomDocument.findMany({
where: { id: { in: uniqueDocIds } },
select: {
id: true,
document: { select: { name: true } },
},
});
const docNameMap = new Map(
dataroomDocuments.map((dd) => [dd.id, dd.document?.name ?? "Untitled"]),
);
const documents = uniqueDocIds.map((id) => ({
documentName: docNameMap.get(id) ?? "Untitled",
}));
const link = viewer.views[0]?.link;
let linkUrl: string | undefined;
if (link?.domainId && link.domainSlug && link.slug) {
linkUrl = `https://${link.domainSlug}/${link.slug}`;
} else if (link) {
linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;
}
if (!linkUrl) return;
const preferencesUrl = generateUnsubscribeUrl({
viewerId: batch.viewerId,
dataroomId: batch.dataroomId,
teamId: batch.teamId,
});
try {
await sendDataroomDigestNotification({
dataroomName: dataroom?.name ?? "Unknown Dataroom",
documents,
senderEmail: senderUser?.email ?? "noreply@papermark.com",
to: viewer.email,
url: linkUrl,
preferencesUrl,
frequency,
});
} catch (error) {
throw new Error(
`Failed to send ${frequency} digest for dataroom "${dataroom?.name}" ` +
`(viewerId: ${batch.viewerId}, dataroomId: ${batch.dataroomId}): ` +
`${(error as Error).message}`,
);
}
}
@@ -0,0 +1,45 @@
import DataroomDigestNotification from "@/components/emails/dataroom-digest-notification";
import { sendEmail } from "@/lib/resend";
export const sendDataroomDigestNotification = async ({
dataroomName,
documents,
senderEmail,
to,
url,
preferencesUrl,
frequency,
}: {
dataroomName: string;
documents: { documentName: string }[];
senderEmail: string;
to: string;
url: string;
preferencesUrl: string;
frequency: "daily" | "weekly";
}) => {
const count = documents.length;
const periodLabel = frequency === "daily" ? "today" : "this week";
try {
await sendEmail({
to,
subject: `${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`,
react: DataroomDigestNotification({
senderEmail,
dataroomName,
documents,
url,
preferencesUrl,
frequency,
}),
test: process.env.NODE_ENV === "development",
system: true,
unsubscribeUrl: preferencesUrl,
});
} catch (e) {
console.error(e);
throw e;
}
};
+5 -2
View File
@@ -1,11 +1,11 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { DocumentStorageType } from "@prisma/client";
import slugify from "@sindresorhus/slugify";
import { put } from "@vercel/blob";
import path from "node:path";
import { match } from "ts-pattern";
import { newId } from "@/lib/id-helper";
import { safeSlugify } from "@/lib/utils";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "../constants";
import { getTeamS3ClientAndConfig } from "./aws-client";
@@ -93,13 +93,16 @@ const putFileInS3Server = async ({
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
const key = `${teamId}/${docId}/${slugify(name)}${ext}`;
const slugifiedName = safeSlugify(name) + ext;
const originalFileName = `${name}${ext}`;
const key = `${teamId}/${docId}/${slugifiedName}`;
const params = {
Bucket: config.bucket,
Key: key,
Body: file.buffer,
ContentType: file.type,
ContentDisposition: `attachment; filename="${slugifiedName}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,
};
// Create a new instance of the PutObjectCommand with the parameters
+8 -6
View File
@@ -148,17 +148,19 @@ const putFileSingle = async ({
);
}
const { url, key, fileName } = (await presignedResponse.json()) as {
url: string;
key: string;
fileName: string;
};
const { url, key, fileName, contentDisposition } =
(await presignedResponse.json()) as {
url: string;
key: string;
fileName: string;
contentDisposition: string;
};
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Disposition": contentDisposition,
},
body: file,
});
+6 -2
View File
@@ -1,9 +1,10 @@
import { Upload } from "@aws-sdk/lib-storage";
import { DocumentStorageType } from "@prisma/client";
import slugify from "@sindresorhus/slugify";
import path from "node:path";
import { Readable } from "stream";
import { safeSlugify } from "@/lib/utils";
import { getTeamS3ClientAndConfig } from "./aws-client";
type StreamFile = {
@@ -26,7 +27,9 @@ export const streamFileServer = async ({
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
const key = `${teamId}/${docId}/${slugify(name)}${ext}`;
const slugifiedName = safeSlugify(name) + ext;
const originalFileName = `${name}${ext}`;
const key = `${teamId}/${docId}/${slugifiedName}`;
const params = {
client,
@@ -35,6 +38,7 @@ export const streamFileServer = async ({
Key: key,
Body: file.stream,
ContentType: file.type,
ContentDisposition: `attachment; filename="${slugifiedName}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,
},
};
+1
View File
@@ -48,4 +48,5 @@ export const newId = new IdGenerator({
token: "pmk",
clickEvent: "click",
preset: "preset",
pending: "pending", // for pending uploads
}).id;
+60 -9
View File
@@ -2,6 +2,36 @@ import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
const LOGIN_PATH = "/login";
const DEFAULT_AUTH_REDIRECT_PATH = "/dashboard";
function normalizeNextPath(nextPath: string | null): string {
if (!nextPath) {
return DEFAULT_AUTH_REDIRECT_PATH;
}
let normalized = nextPath;
// Handle already-encoded and double-encoded `next` values.
for (let i = 0; i < 3; i += 1) {
try {
const decoded = decodeURIComponent(normalized);
if (decoded === normalized) {
break;
}
normalized = decoded;
} catch {
break;
}
}
if (!normalized.startsWith("/")) {
return DEFAULT_AUTH_REDIRECT_PATH;
}
return normalized;
}
export default async function AppMiddleware(req: NextRequest) {
const url = req.nextUrl;
const path = url.pathname;
@@ -17,18 +47,39 @@ export default async function AppMiddleware(req: NextRequest) {
};
// UNAUTHENTICATED if there's no token and the path isn't /login, redirect to /login
if (!token?.email && path !== "/login") {
const loginUrl = new URL(`/login`, req.url);
if (!token?.email && path !== LOGIN_PATH) {
const loginUrl = new URL(LOGIN_PATH, req.url);
// Append "next" parameter only if not navigating to the root
if (path !== "/") {
const nextPath =
path === "/auth/confirm-email-change" ? `${path}${url.search}` : path;
loginUrl.searchParams.set("next", encodeURIComponent(nextPath));
loginUrl.searchParams.set("next", nextPath);
}
return NextResponse.redirect(loginUrl);
}
if (!token?.email && path === LOGIN_PATH) {
const rawNextPath = url.searchParams.get("next");
if (rawNextPath) {
const normalizedNextPath = normalizeNextPath(rawNextPath);
const canonicalLoginUrl = new URL(LOGIN_PATH, req.url);
canonicalLoginUrl.searchParams.set("next", normalizedNextPath);
if (canonicalLoginUrl.search !== url.search) {
return NextResponse.redirect(canonicalLoginUrl, { status: 308 });
}
// Keep the base /login URL indexable for now, but deindex parameterized variants.
const response = NextResponse.next();
response.headers.set("X-Robots-Tag", "noindex, nofollow");
return response;
}
return NextResponse.next();
}
// AUTHENTICATED if the user was created in the last 10 seconds, redirect to "/welcome"
if (
token?.email &&
@@ -40,11 +91,11 @@ export default async function AppMiddleware(req: NextRequest) {
return NextResponse.redirect(new URL("/welcome", req.url));
}
// AUTHENTICATED if the path is /login, redirect to "/dashboard"
if (token?.email && path === "/login") {
const nextPath = url.searchParams.get("next") || "/dashboard"; // Default redirection to "/dashboard" if no next parameter
return NextResponse.redirect(
new URL(decodeURIComponent(nextPath), req.url),
);
// AUTHENTICATED if the path is /login, redirect to the next path
if (token?.email && path === LOGIN_PATH) {
const nextPath = normalizeNextPath(url.searchParams.get("next"));
return NextResponse.redirect(new URL(nextPath, req.url));
}
return NextResponse.next();
}
+10 -29
View File
@@ -1,41 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { BLOCKED_PATHNAMES } from "@/lib/constants";
import { getDomainRedirectUrl } from "@/lib/api/domains/redis";
export default async function DomainMiddleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const host = req.headers.get("host");
// If it's the root path, redirect to papermark.com
// If it's the root path, check for a configured redirect URL in Redis
if (path === "/") {
if (host === "guide.permithealth.com") {
return NextResponse.redirect(
new URL("https://guide.permithealth.com/faq", req.url),
);
}
if (host === "fund.tradeair.in") {
return NextResponse.redirect(
new URL("https://tradeair.in/sv-fm-inbound", req.url),
);
}
if (host === "docs.pashupaticapital.com") {
return NextResponse.redirect(
new URL("https://www.pashupaticapital.com/", req.url),
);
}
if (host === "partners.braxtech.net") {
return NextResponse.redirect(
new URL("https://partners.braxtech.net/investors", req.url),
);
}
if (host === "research.elazaradvisors.com") {
return NextResponse.redirect(
new URL("https://research.elazaradvisors.com/root", req.url),
);
if (host) {
const redirectUrl = await getDomainRedirectUrl(host);
if (redirectUrl) {
// 302: intentionally non-permanent since the target is user-configurable
return NextResponse.redirect(new URL(redirectUrl, req.url), {
status: 302,
});
}
}
return NextResponse.redirect(new URL("https://www.papermark.com", req.url));
+121
View File
@@ -0,0 +1,121 @@
import { redis } from "@/lib/redis";
const ITEM_TTL_SECONDS = 8 * 24 * 60 * 60; // 8 days
type QueueItem = {
dataroomDocumentId: string;
senderUserId: string;
queuedAt: number;
};
type DigestViewerEntry = {
viewerId: string;
dataroomId: string;
teamId: string;
};
function itemsKey(viewerId: string, dataroomId: string) {
return `dataroom_digest_items:${viewerId}:${dataroomId}`;
}
function viewerSetKey(frequency: "daily" | "weekly") {
return `dataroom_digest_viewers:${frequency}`;
}
function encodeViewerEntry(entry: DigestViewerEntry): string {
return `${entry.viewerId}:${entry.dataroomId}:${entry.teamId}`;
}
function decodeViewerEntry(encoded: string): DigestViewerEntry {
const [viewerId, dataroomId, teamId] = encoded.split(":");
return { viewerId, dataroomId, teamId };
}
export async function queueNotification({
frequency,
viewerId,
dataroomId,
teamId,
dataroomDocumentId,
senderUserId,
}: {
frequency: "daily" | "weekly";
viewerId: string;
dataroomId: string;
teamId: string;
dataroomDocumentId: string;
senderUserId: string;
}) {
const key = itemsKey(viewerId, dataroomId);
const item: QueueItem = {
dataroomDocumentId,
senderUserId,
queuedAt: Date.now(),
};
const pipeline = redis.pipeline();
pipeline.rpush(key, JSON.stringify(item));
pipeline.expire(key, ITEM_TTL_SECONDS);
pipeline.sadd(
viewerSetKey(frequency),
encodeViewerEntry({ viewerId, dataroomId, teamId }),
);
await pipeline.exec();
}
export type DigestBatch = {
viewerId: string;
dataroomId: string;
teamId: string;
items: QueueItem[];
};
export async function popDigestQueue(
frequency: "daily" | "weekly",
): Promise<DigestBatch[]> {
const setKey = viewerSetKey(frequency);
const members = await redis.smembers(setKey);
if (!members || members.length === 0) return [];
// Remove the entire set atomically
await redis.del(setKey);
const batches: DigestBatch[] = [];
for (const member of members) {
const entry = decodeViewerEntry(member);
const key = itemsKey(entry.viewerId, entry.dataroomId);
// Get all items then delete the list
const rawItems = await redis.lrange(key, 0, -1);
await redis.del(key);
if (!rawItems || rawItems.length === 0) continue;
const items: QueueItem[] = rawItems
.map((raw) => {
try {
return typeof raw === "string"
? (JSON.parse(raw) as QueueItem)
: (raw as QueueItem);
} catch (error) {
console.warn(
`[dataroom-digest] Skipping corrupted queue item for viewer=${entry.viewerId} dataroom=${entry.dataroomId}:`,
{ raw, error },
);
return null;
}
})
.filter((item): item is QueueItem => item !== null);
batches.push({
viewerId: entry.viewerId,
dataroomId: entry.dataroomId,
teamId: entry.teamId,
items,
});
}
return batches;
}
+6 -1
View File
@@ -69,7 +69,12 @@ export const sendEmail = async ({
text: plainText,
headers: {
"X-Entity-Ref-ID": nanoid(),
...(unsubscribeUrl ? { "List-Unsubscribe": unsubscribeUrl } : {}),
...(unsubscribeUrl
? {
"List-Unsubscribe": `<${unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
: {}),
},
});
@@ -0,0 +1,88 @@
import { useTeam } from "@/context/team-context";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
export type DocumentViewStats = {
viewId: string;
documentId: string;
totalDuration: number;
pagesViewed: number;
totalPages: number;
completionRate: number;
};
export function useDataroomViewDocumentStats({
dataroomId,
dataroomViewId,
enabled = false,
}: {
dataroomId: string;
dataroomViewId: string;
enabled?: boolean;
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const canFetch = !!(enabled && teamId && dataroomId && dataroomViewId);
const { data, error } = useSWR<{ documentStats: DocumentViewStats[] }>(
canFetch
? `/api/teams/${teamId}/datarooms/${dataroomId}/views/${dataroomViewId}/document-stats`
: null,
fetcher,
{
dedupingInterval: 30000,
revalidateOnFocus: false,
},
);
return {
documentStats: data?.documentStats,
loading: canFetch && !error && !data,
error,
};
}
type PageDurationData = {
pageNumber: string;
sum_duration: number;
};
export function useDataroomDocumentPageStats({
dataroomId,
dataroomViewId,
documentViewId,
documentId,
enabled = false,
}: {
dataroomId: string;
dataroomViewId: string;
documentViewId: string;
documentId: string;
enabled?: boolean;
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const canFetch = !!(
enabled && teamId && dataroomId && dataroomViewId && documentViewId && documentId
);
const { data, error } = useSWR<{ duration: { data: PageDurationData[] } }>(
canFetch
? `/api/teams/${teamId}/datarooms/${dataroomId}/views/${dataroomViewId}/document-stats?documentViewId=${documentViewId}&documentId=${documentId}`
: null,
fetcher,
{
dedupingInterval: 30000,
revalidateOnFocus: false,
},
);
return {
duration: data?.duration,
loading: canFetch && !error && !data,
error,
};
}
+1 -61
View File
@@ -1,4 +1,4 @@
import { Document, DocumentVersion, Domain, Link, View } from "@prisma/client";
import { Document, DocumentVersion, Link, View } from "@prisma/client";
import prisma from "@/lib/prisma";
import { decryptEncrpytedPassword } from "@/lib/utils";
@@ -13,14 +13,6 @@ interface ITeamUserAndDocument {
options?: {};
}
interface ITeamWithDomain {
teamId: string;
userId: string;
domain?: string;
teamOptions?: {};
options?: {};
}
interface IDocumentWithLink {
docId: string;
userId: string;
@@ -94,58 +86,6 @@ export async function getTeamWithUsersAndDocument({
return { team, document };
}
export async function getTeamWithDomain({
teamId,
userId,
domain: domainSlug,
teamOptions,
options,
}: ITeamWithDomain) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
users: {
select: {
userId: true,
},
},
domains: {
...options,
},
},
});
// check if the team exists
if (!team) {
throw new TeamError("Team doesn't exists");
}
// check if the user is part the team
const teamHasUser = team?.users.some((user) => user.userId === userId);
if (!teamHasUser) {
throw new TeamError("You are not a member of the team");
}
// check if the team has a paid plan
const teamHasPaidPlan = team?.plan !== "free";
if (!teamHasPaidPlan) {
throw new TeamError("Team doesn't have a paid plan");
}
// check if the domain exists in the team
let domain: Domain | undefined;
if (domainSlug) {
domain = team.domains.find((_domain) => _domain.slug === domainSlug);
if (!domain) {
throw new TeamError("Domain doesn't exists in the team");
}
}
return { team, domain };
}
export async function getDocumentWithTeamAndUser({
docId,
userId,
@@ -0,0 +1,16 @@
VERSION 1
NODE endpoint
SQL >
%
SELECT
viewId,
documentId,
SUM(duration) AS sum_duration,
COUNT(DISTINCT pageNumber) AS pages_viewed
FROM
page_views__v3
WHERE
viewId IN splitByChar(',', {{ String(viewIds, required=True) }})
GROUP BY
viewId, documentId
+13
View File
@@ -213,6 +213,19 @@ export const getClickEventsByView = tb.buildPipe({
}),
});
export const getDataroomViewDocumentStats = tb.buildPipe({
pipe: "get_dataroom_view_document_stats__v1",
parameters: z.object({
viewIds: z.string().describe("Comma separated viewIds"),
}),
data: z.object({
viewId: z.string(),
documentId: z.string(),
sum_duration: z.number(),
pages_viewed: z.number(),
}),
});
export const getTotalTeamDuration = tb.buildPipe({
pipe: "get_total_team_duration__v1",
parameters: z.object({
+33 -6
View File
@@ -1,6 +1,7 @@
import { logger, task } from "@trigger.dev/sdk/v3";
import prisma from "@/lib/prisma";
import { queueNotification } from "@/lib/redis/dataroom-notification-queue";
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
type NotificationPayload = {
@@ -14,7 +15,6 @@ export const sendDataroomChangeNotificationTask = task({
id: "send-dataroom-change-notification",
retry: { maxAttempts: 3 },
run: async (payload: NotificationPayload) => {
// Get all verified viewers for this dataroom
const viewers = await prisma.viewer.findMany({
where: {
teamId: payload.teamId,
@@ -62,13 +62,11 @@ export const sendDataroomChangeNotificationTask = task({
return;
}
// Construct simplified viewer objects with email and link info, excluding expired/archived links
const viewersWithLinks = viewers
.map((viewer) => {
const view = viewer.views[0];
const link = view?.link;
// Skip if link is expired or archived
if (
!link ||
link.isArchived ||
@@ -77,11 +75,11 @@ export const sendDataroomChangeNotificationTask = task({
return null;
}
// Skip if notifications are disabled for this dataroom
const parsedPreferences =
ZViewerNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
if (
parsedPreferences.success &&
parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false
@@ -89,6 +87,12 @@ export const sendDataroomChangeNotificationTask = task({
return null;
}
const frequency =
parsedPreferences.success
? (parsedPreferences.data.dataroom[payload.dataroomId]?.frequency ??
"instant")
: "instant";
let linkUrl = "";
if (link.domainId && link.domainSlug && link.slug) {
linkUrl = `https://${link.domainSlug}/${link.slug}`;
@@ -99,19 +103,42 @@ export const sendDataroomChangeNotificationTask = task({
return {
id: viewer.id,
linkUrl,
frequency,
};
})
.filter(
(viewer): viewer is { id: string; linkUrl: string } => viewer !== null,
(
viewer,
): viewer is {
id: string;
linkUrl: string;
frequency: "instant" | "daily" | "weekly";
} => viewer !== null,
);
logger.info("Processed viewer links", {
viewerCount: viewersWithLinks.length,
});
// Send notification to each viewer
for (const viewer of viewersWithLinks) {
try {
if (viewer.frequency === "daily" || viewer.frequency === "weekly") {
await queueNotification({
frequency: viewer.frequency,
viewerId: viewer.id,
dataroomId: payload.dataroomId,
teamId: payload.teamId,
dataroomDocumentId: payload.dataroomDocumentId,
senderUserId: payload.senderUserId,
});
logger.info("Queued notification for digest", {
viewerId: viewer.id,
frequency: viewer.frequency,
});
continue;
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-dataroom-new-document-notification`,
{
+13 -1
View File
@@ -2,6 +2,7 @@ import { NextRouter } from "next/router";
import slugify from "@sindresorhus/slugify";
import { upload } from "@vercel/blob/client";
import { transliterate } from "transliteration";
import bcrypt from "bcryptjs";
import * as chrono from "chrono-node";
import { type ClassValue, clsx } from "clsx";
@@ -351,6 +352,17 @@ export const nanoid = customAlphabet(
7,
); // 7-character random string
/**
* CJK-safe slugify: transliterates non-Latin characters (CJK, Cyrillic, etc.)
* to their romanized equivalents before slugifying, so the same input always
* produces the same slug. e.g. "文件报告" "wen-jian-bao-gao"
*/
export function safeSlugify(input: string): string {
const slug = slugify(input);
if (slug.length > 0) return slug;
return slugify(transliterate(input)) || nanoid();
}
export const daysLeft = (
accountCreationDate: Date,
maxDays: number,
@@ -635,7 +647,7 @@ export const getBreadcrumbPath = (path: string[]) => {
return [
{ name: "Home", pathLink: "/documents" },
...segments.map((segment, index) => {
currentPath += `/${slugify(segment)}`;
currentPath += `/${safeSlugify(segment)}`;
return {
name: segment,
pathLink: currentPath,
+3 -1
View File
@@ -19,7 +19,9 @@ export function sanitizePlainText(content: string) {
.trim();
}
export function validateContent(html: string, length: number = 1000) {
export const MAX_MESSAGE_LENGTH = 4000;
export function validateContent(html: string, length: number = MAX_MESSAGE_LENGTH) {
if (html.length > length) {
throw new Error(`Content cannot be longer than ${length} characters`);
}
+6 -4
View File
@@ -11,16 +11,18 @@ type UnsubscribePayload = {
};
export function generateUnsubscribeUrl(payload: UnsubscribePayload): string {
// Add expiration of 3 months
const tokenPayload = {
...payload,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90,
};
const token = jwt.sign(tokenPayload, JWT_SECRET);
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/${
payload.dataroomId ? "dataroom" : "yir"
}?token=${token}`;
if (payload.dataroomId) {
return `${UNSUBSCRIBE_BASE_URL}/api/notification-preferences/dataroom?token=${token}`;
}
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/yir?token=${token}`;
}
export function verifyUnsubscribeToken(
+4
View File
@@ -1,10 +1,14 @@
import { z } from "zod";
export const NotificationFrequency = z.enum(["instant", "daily", "weekly"]);
export type NotificationFrequency = z.infer<typeof NotificationFrequency>;
export const ZViewerNotificationPreferencesSchema = z
.object({
dataroom: z.record(
z.object({
enabled: z.boolean(),
frequency: NotificationFrequency.optional().default("instant"),
}),
),
})
+3 -2
View File
@@ -41,9 +41,9 @@ export const config = {
* 2. /_next/ (Next.js internals)
* 3. /_static (inside /public)
* 4. /_vercel (Vercel internals)
* 5. /favicon.ico, /sitemap.xml (static files)
* 5. /favicon.ico, /sitemap.xml, /robots.txt (static files)
*/
"/((?!api/|_next/|_static|vendor|_icons|_vercel|favicon.ico|sitemap.xml).*)",
"/((?!api/|_next/|_static|vendor|_icons|_vercel|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
@@ -70,6 +70,7 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) {
!path.startsWith("/view/") &&
!path.startsWith("/verify") &&
!path.startsWith("/unsubscribe") &&
!path.startsWith("/notification-preferences") &&
!path.startsWith("/auth/email")
) {
return AppMiddleware(req);
+23
View File
@@ -91,6 +91,21 @@ const nextConfig = {
},
],
},
{
source: "/login",
has: [
{
type: "query",
key: "next",
},
],
headers: [
{
key: "X-Robots-Tag",
value: "noindex, nofollow",
},
],
},
{
// Embed routes - allow iframe embedding
source: "/view/:path*/embed",
@@ -170,6 +185,14 @@ const nextConfig = {
...config.resolve.alias,
"@google-cloud/kms": false,
"@google-cloud/secret-manager": false,
// Jackson pulls TypeORM/Mongo optional drivers we don't use (Postgres-only setup).
// Aliasing prevents noisy module resolution warnings in dev/build.
mysql: false,
"react-native-sqlite-storage": false,
aws4: false,
"@sap/hana-client": false,
"@sap/hana-client/extension/Stream": false,
"hdb-pool": false,
};
// Suppress critical dependency warnings from Jackson's dynamic requires
+2630 -1756
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -20,15 +20,15 @@
"dev:prisma": "npx prisma generate && npx prisma migrate deploy"
},
"dependencies": {
"@ai-sdk/google-vertex": "^4.0.59",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/react": "^3.0.93",
"@ai-sdk/google-vertex": "^4.0.68",
"@ai-sdk/openai": "^3.0.37",
"@ai-sdk/react": "^3.0.107",
"@aws-sdk/client-lambda": "^3.993.0",
"@aws-sdk/client-s3": "^3.993.0",
"@aws-sdk/cloudfront-signer": "^3.993.0",
"@aws-sdk/cloudfront-signer": "^3.999.0",
"@aws-sdk/lib-storage": "^3.993.0",
"@aws-sdk/s3-request-presigner": "^3.993.0",
"@boxyhq/saml-jackson": "1.52.1",
"@boxyhq/saml-jackson": "1.52.2",
"@calcom/embed-react": "^1.5.3",
"@chronark/zod-bird": "^0.3.10",
"@dnd-kit/core": "^6.3.1",
@@ -37,7 +37,7 @@
"@github/webauthn-json": "^2.1.1",
"@hookform/resolvers": "^5.2.2",
"@jitsu/js": "^1.10.4",
"@libpdf/core": "^0.2.8",
"@libpdf/core": "^0.2.10",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/third-parties": "^16.1.6",
"@pdf-lib/fontkit": "^1.1.1",
@@ -86,12 +86,12 @@
"@tus/utils": "^0.5.1",
"@upstash/qstash": "^2.9.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.2",
"@upstash/redis": "^1.36.3",
"@vercel/blob": "^2.0.1",
"@vercel/edge-config": "^1.4.3",
"@vercel/functions": "^3.4.2",
"ai": "^6.0.91",
"autoprefixer": "^10.4.24",
"@vercel/functions": "^3.4.3",
"ai": "^6.0.105",
"autoprefixer": "^10.4.27",
"base-x": "^5.0.1",
"bcryptjs": "^3.0.3",
"bottleneck": "^2.19.5",
@@ -110,9 +110,9 @@
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.574.0",
"lucide-react": "^0.575.0",
"mime-types": "^3.0.2",
"motion": "^12.34.2",
"motion": "^12.34.3",
"ms": "^2.1.3",
"mupdf": "^1.27.0",
"nanoid": "^5.1.6",
@@ -122,40 +122,41 @@
"nodemailer": "^7.0.13",
"notion-client": "^7.7.3",
"notion-utils": "^7.7.3",
"nuqs": "^2.8.8",
"openai": "^6.22.0",
"nuqs": "^2.8.9",
"openai": "^6.25.0",
"pdf-lib": "^1.17.1",
"postcss": "^8.5.6",
"posthog-js": "^1.351.3",
"posthog-js": "^1.356.1",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.5.0",
"react-dropzone": "^14.4.1",
"react-email": "^5.2.8",
"react-hook-form": "^7.71.1",
"react-email": "^5.2.9",
"react-hook-form": "^7.71.2",
"react-hotkeys-hook": "^5.2.4",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"react-notion-x": "^7.7.3",
"react-pdf": "^8.0.2",
"react-phone-number-input": "^3.4.14",
"react-phone-number-input": "^3.4.16",
"react-resizable-panels": "^3.0.6",
"react-textarea-autosize": "^8.5.9",
"react-zoom-pan-pinch": "^3.7.0",
"resend": "^6.9.2",
"resend": "^6.9.3",
"sanitize-html": "^2.17.1",
"shiki": "^3.22.0",
"shiki": "^3.23.0",
"sonner": "^2.0.7",
"streamdown": "^2.2.0",
"streamdown": "^2.3.0",
"stripe": "^16.12.0",
"swr": "^2.4.0",
"swr": "^2.4.1",
"tailwind-merge": "^2.6.1",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tokenlens": "^1.3.1",
"transliteration": "^2.6.1",
"ts-pattern": "^5.9.0",
"tus-js-client": "^4.3.1",
"ua-parser-js": "^1.0.41",
@@ -167,7 +168,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@react-email/preview-server": "^5.2.8",
"@react-email/preview-server": "^5.2.9",
"@tailwindcss/forms": "^0.5.11",
"@trigger.dev/build": "^3.3.17",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
+2
View File
@@ -10,6 +10,7 @@ import { NuqsAdapter } from "nuqs/adapters/next/pages";
import { EXCLUDED_PATHS } from "@/lib/constants";
import { PostHogCustomProvider } from "@/components/providers/posthog-provider";
import { DealflowPopup } from "@/components/shared/dealflow-popup";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -82,6 +83,7 @@ export default function App({
) : (
<TeamProvider>
<Component {...pageProps} />
<DealflowPopup />
</TeamProvider>
)}
</TooltipProvider>
-30
View File
@@ -1,5 +1,4 @@
import { NextPage } from "next";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
@@ -7,45 +6,16 @@ import { toast } from "sonner";
import { AccountHeader } from "@/components/account/account-header";
import { UpdateMailSubscribe } from "@/components/account/update-subscription";
import UploadAvatar from "@/components/account/upload-avatar";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import AppLayout from "@/components/layouts/app";
import { Form } from "@/components/ui/form";
import { PlanEnum } from "@/ee/stripe/constants";
import { usePlan } from "@/lib/swr/use-billing";
import { validateEmail } from "@/lib/utils/validate-email";
const ProfilePage: NextPage = () => {
const { data: session, update } = useSession();
const { plan: teamPlan, isAnnualPlan, isFree } = usePlan();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
// Determine the next plan to highlight
const getNextPlan = () => {
if (isFree) return PlanEnum.Pro;
if (teamPlan === "pro") return PlanEnum.Business;
if (teamPlan === "business") return PlanEnum.DataRooms;
return PlanEnum.Business; // Default
};
const nextPlan = getNextPlan();
// Show modal for monthly subscribers and free users when opening account
useEffect(() => {
if (!isAnnualPlan) {
// Show modal for monthly subscribers and free users
setShowUpgradeModal(true);
}
}, [isAnnualPlan]);
return (
<AppLayout>
<UpgradePlanModal
clickedPlan={nextPlan}
trigger="account_page"
open={showUpgradeModal}
setOpen={setShowUpgradeModal}
/>
<main className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<AccountHeader />
<div className="space-y-6">
+8 -4
View File
@@ -2,12 +2,12 @@ import { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import slugify from "@sindresorhus/slugify";
import { getServerSession } from "next-auth";
import path from "node:path";
import { ONE_HOUR, ONE_SECOND } from "@/lib/constants";
import { getTeamS3ClientAndConfig } from "@/lib/files/aws-client";
import { safeSlugify } from "@/lib/utils";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
@@ -53,8 +53,10 @@ export default async function handler(
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
const slugifiedName = slugify(name) + ext;
const slugifiedName = safeSlugify(name) + ext;
const originalFileName = `${name}${ext}`;
const key = `${team.id}/${docId}/${slugifiedName}`;
const contentDisposition = `attachment; filename="${slugifiedName}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;
const { client, config } = await getTeamS3ClientAndConfig(team.id);
@@ -62,14 +64,16 @@ export default async function handler(
Bucket: config.bucket,
Key: key,
ContentType: contentType,
ContentDisposition: `attachment; filename="${slugifiedName}"`,
ContentDisposition: contentDisposition,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return res.status(200).json({ url, key, docId, fileName: slugifiedName });
return res
.status(200)
.json({ url, key, docId, fileName: slugifiedName, contentDisposition });
} catch (error) {
return res.status(500).json({ error: "Internal server error" });
}
+4 -3
View File
@@ -7,12 +7,12 @@ import {
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import slugify from "@sindresorhus/slugify";
import { getServerSession } from "next-auth";
import path from "node:path";
import { ONE_HOUR, ONE_SECOND } from "@/lib/constants";
import { getTeamS3ClientAndConfig } from "@/lib/files/aws-client";
import { safeSlugify } from "@/lib/utils";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { MultipartUploadSchema } from "@/lib/zod/schemas/multipart";
@@ -68,7 +68,8 @@ export default async function handler(
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
const slugifiedName = slugify(name) + ext;
const slugifiedName = safeSlugify(name) + ext;
const originalFileName = `${name}${ext}`;
const key = `${team.id}/${docId}/${slugifiedName}`;
const { client, config } = await getTeamS3ClientAndConfig(team.id);
@@ -80,7 +81,7 @@ export default async function handler(
Bucket: config.bucket,
Key: key,
ContentType: contentType,
ContentDisposition: `attachment; filename="${slugifiedName}"`,
ContentDisposition: `attachment; filename="${slugifiedName}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,
});
const createResponse = await client.send(createCommand);
+4 -3
View File
@@ -2,12 +2,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { MultiRegionS3Store } from "@/ee/features/storage/s3-store";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import slugify from "@sindresorhus/slugify";
import { Server } from "@tus/server";
import path from "node:path";
import { verifyDataroomSessionInPagesRouter } from "@/lib/auth/dataroom-auth";
import { getTeamS3ClientAndConfig } from "@/lib/files/aws-client";
import { safeSlugify } from "@/lib/utils";
import { RedisLocker } from "@/lib/files/tus-redis-locker";
import { newId } from "@/lib/id-helper";
import prisma from "@/lib/prisma";
@@ -78,7 +78,7 @@ const tusServer = new Server({
const docId = newId("doc");
const { name, ext } = path.parse(fileName);
const newName = `${teamIdToUse}/${docId}/${slugify(name)}${ext}`;
const newName = `${teamIdToUse}/${docId}/${safeSlugify(name)}${ext}`;
return newName;
},
generateUrl(req, { proto, host, path, id }) {
@@ -147,7 +147,8 @@ const tusServer = new Server({
const metadata = upload.metadata || {};
const contentType = metadata.contentType || "application/octet-stream";
const { name, ext } = path.parse(metadata.fileName!);
const contentDisposition = `attachment; filename="${slugify(name)}${ext}"`;
const originalFileName = `${name}${ext}`;
const contentDisposition = `attachment; filename="${safeSlugify(name)}${ext}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;
// The Key (object path) where the file was uploaded
const objectKey = upload.id;
+177 -7
View File
@@ -1,8 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { isTeamPausedById } from "@/ee/features/billing/cancellation/lib/is-team-paused";
import { getLimits } from "@/ee/limits/server";
import { MultiRegionS3Store } from "@/ee/features/storage/s3-store";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import slugify from "@sindresorhus/slugify";
import { Server } from "@tus/server";
import { getServerSession } from "next-auth/next";
import path from "node:path";
@@ -10,8 +11,14 @@ import path from "node:path";
import { getTeamS3ClientAndConfig } from "@/lib/files/aws-client";
import { RedisLocker } from "@/lib/files/tus-redis-locker";
import { newId } from "@/lib/id-helper";
import prisma from "@/lib/prisma";
import { lockerRedisClient } from "@/lib/redis";
import { log } from "@/lib/utils";
import { CustomUser } from "@/lib/types";
import { log, safeSlugify } from "@/lib/utils";
import {
getFileSizeLimit,
getFileSizeLimits,
} from "@/lib/utils/get-file-size-limits";
import { authOptions } from "../../auth/[...nextauth]";
@@ -26,6 +33,15 @@ const locker = new RedisLocker({
redisClient: lockerRedisClient,
});
const FREE_PLAN = "free";
const FREE_TRIAL_PLAN = "free+drtrial";
const BYTES_PER_MEGABYTE = 1024 * 1024;
type TusErrorResponse = { status_code: number; body: string };
type TusAuthenticatedRequest = NextApiRequest & {
papermarkUserId?: string;
};
const tusServer = new Server({
// `path` needs to match the route declared by the next file router
path: "/api/file/tus",
@@ -40,7 +56,7 @@ const tusServer = new Server({
};
const docId = newId("doc");
const { name, ext } = path.parse(fileName);
const newName = `${teamId}/${docId}/${slugify(name)}${ext}`;
const newName = `${teamId}/${docId}/${safeSlugify(name)}${ext}`;
return newName;
},
generateUrl(req, { proto, host, path, id }) {
@@ -54,18 +70,166 @@ const tusServer = new Server({
return Buffer.from(id, "base64url").toString("utf-8");
},
onResponseError(req, res, err) {
if (typeof err === "object" && err !== null) {
const tusError = err as { status_code?: unknown; body?: unknown };
if (
typeof tusError.status_code === "number" &&
typeof tusError.body === "string"
) {
const errorResponse: TusErrorResponse = {
status_code: tusError.status_code,
body: tusError.body,
};
return errorResponse;
}
}
log({
message: "Error uploading a file. Error: \n\n" + err,
type: "error",
});
return { status_code: 500, body: "Internal Server Error" };
},
async onIncomingRequest(req, res, uploadId) {
const userId = (req as TusAuthenticatedRequest).papermarkUserId;
if (!userId) {
throw { status_code: 401, body: "Unauthorized" };
}
// Upload creation is validated in onUploadCreate; here we protect follow-up
// requests (HEAD/PATCH/DELETE) so only team members can touch an upload URL.
if (!uploadId || req.method === "POST") {
return;
}
const decodedUploadId = uploadId.includes("/")
? uploadId
: Buffer.from(uploadId, "base64url").toString("utf-8");
const uploadTeamId = decodedUploadId.split("/")[0];
if (!uploadTeamId) {
throw { status_code: 400, body: "Invalid upload id" };
}
const hasTeamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId: uploadTeamId,
},
},
select: {
userId: true,
},
});
if (!hasTeamAccess) {
throw { status_code: 403, body: "Unauthorized to access this team" };
}
},
async onUploadCreate(req, res, upload) {
const userId = (req as TusAuthenticatedRequest).papermarkUserId;
if (!userId) {
throw { status_code: 401, body: "Unauthorized" };
}
const metadata = upload.metadata || {};
const teamId = metadata.teamId;
const fileName = metadata.fileName;
const contentType = metadata.contentType || "application/octet-stream";
if (!teamId || !fileName) {
throw { status_code: 400, body: "Missing required upload metadata" };
}
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId,
},
},
},
select: {
plan: true,
},
});
if (!team) {
throw { status_code: 403, body: "Unauthorized to access this team" };
}
const [limits, teamIsPaused] = await Promise.all([
getLimits({ teamId, userId }),
isTeamPausedById(teamId),
]);
if (teamIsPaused) {
throw {
status_code: 403,
body: "Team is currently paused. New document uploads are not available.",
};
}
const documentLimit = limits.documents;
if (
typeof documentLimit === "number" &&
Number.isFinite(documentLimit) &&
limits.usage.documents >= documentLimit
) {
throw {
status_code: 403,
body: "You have reached the team document limit",
};
}
const uploadSize = upload.size;
if (
typeof uploadSize !== "number" ||
!Number.isFinite(uploadSize) ||
uploadSize <= 0
) {
throw { status_code: 400, body: "Missing or invalid upload length" };
}
const isFree = team.plan === FREE_PLAN || team.plan === FREE_TRIAL_PLAN;
const isTrial = team.plan.includes("drtrial");
const teamFileSizeLimitConfig: Parameters<typeof getFileSizeLimits>[0]["limits"] =
"fileSizeLimits" in limits &&
typeof limits.fileSizeLimits === "object" &&
limits.fileSizeLimits !== null
? {
fileSizeLimits: limits.fileSizeLimits as Record<
string,
number | undefined
>,
}
: undefined;
const fileSizeLimits = getFileSizeLimits({
limits: teamFileSizeLimitConfig,
isFree,
isTrial,
});
const fileSizeLimitMb = getFileSizeLimit(contentType, fileSizeLimits);
const fileSizeLimitBytes = fileSizeLimitMb * BYTES_PER_MEGABYTE;
if (uploadSize > fileSizeLimitBytes) {
throw {
status_code: 413,
body: `File size too big for ${contentType} (max. ${fileSizeLimitMb} MB)`,
};
}
return res;
},
async onUploadFinish(req, res, upload) {
try {
const metadata = upload.metadata || {};
const contentType = metadata.contentType || "application/octet-stream";
const { name, ext } = path.parse(metadata.fileName!);
const contentDisposition = `attachment; filename="${slugify(name)}${ext}"`;
const originalFileName = `${name}${ext}`;
const contentDisposition = `attachment; filename="${safeSlugify(name)}${ext}"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;
// The Key (object path) where the file was uploaded
const objectKey = upload.id;
@@ -99,12 +263,18 @@ const tusServer = new Server({
},
});
export default function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Get the session
const session = getServerSession(req, res, authOptions);
if (!session) {
const session = await getServerSession(req, res, authOptions);
const userId = (session?.user as CustomUser | undefined)?.id;
if (!userId) {
return res.status(401).json({ message: "Unauthorized" });
}
(req as TusAuthenticatedRequest).papermarkUserId = userId;
return tusServer.handle(req, res);
}
+9 -1
View File
@@ -3,6 +3,8 @@ import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { getFileForDocumentPage } from "@/lib/documents/get-file-helper";
import { ratelimit } from "@/lib/redis";
import { CustomUser } from "@/lib/types";
import { authOptions } from "../auth/[...nextauth]";
@@ -10,7 +12,6 @@ export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
// We only allow GET requests
if (req.method !== "GET") {
res.status(405).json({ message: "Method Not Allowed" });
return;
@@ -21,6 +22,13 @@ export default async function handle(
return res.status(401).end("Unauthorized");
}
const { success } = await ratelimit(150, "1 m").limit(
`get-thumbnail:${(session.user as CustomUser).id}`,
);
if (!success) {
return res.status(429).json({ message: "Too many requests" });
}
const { documentId, pageNumber, versionNumber } = req.query as {
documentId: string;
pageNumber: string;
-1
View File
@@ -64,7 +64,6 @@ export default async function handle(
showBanner: true,
enableWatermark: true,
watermarkConfig: true,
enableConversation: true,
groupId: true,
permissionGroupId: true,
audienceType: true,
+4 -4
View File
@@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from "next";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
import { ItemType, ViewType } from "@prisma/client";
import slugify from "@sindresorhus/slugify";
import { verifyDataroomSessionInPagesRouter } from "@/lib/auth/dataroom-auth";
import {
@@ -13,6 +12,7 @@ import { notifyDocumentDownload } from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { downloadJobStore } from "@/lib/redis-download-job-store";
import { bulkDownloadTask } from "@/lib/trigger/bulk-download";
import { safeSlugify } from "@/lib/utils";
import { getIpAddress } from "@/lib/utils/ip";
export const config = {
@@ -272,7 +272,7 @@ export default async function handler(
relativePath = match ? match[1] : "";
}
const pathParts = [slugify(rootFolderInfo.name)];
const pathParts = [safeSlugify(rootFolderInfo.name)];
if (relativePath) {
pathParts.push(...relativePath.split("/").filter(Boolean));
}
@@ -357,10 +357,10 @@ export default async function handler(
}
}
const rootPath = "/" + slugify(rootFolder.name);
const rootPath = "/" + safeSlugify(rootFolder.name);
if (!folderStructure[rootPath]) {
folderStructure[rootPath] = {
name: slugify(rootFolder.name),
name: safeSlugify(rootFolder.name),
path: rootPath,
files: [],
};
+2 -1
View File
@@ -179,7 +179,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
if (link.href) {
const matchedKeyword = keywords.find(
(keyword) =>
typeof keyword === "string" && link.href.includes(keyword),
typeof keyword === "string" &&
link.href.toLowerCase().includes(keyword.toLowerCase()),
);
if (matchedKeyword) {
@@ -0,0 +1,134 @@
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
const UpdatePreferencesSchema = z.object({
frequency: z.enum(["instant", "daily", "weekly", "disabled"]),
});
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET" && req.method !== "POST") {
res.status(405).json({ message: "Method Not Allowed" });
return;
}
const { token } = req.query as { token: string };
if (!token) {
res.status(400).json({ message: "Token is required" });
return;
}
const payload = verifyUnsubscribeToken(token);
if (!payload) {
res.status(400).json({ message: "Invalid or expired token" });
return;
}
if (payload.exp && payload.exp < Date.now() / 1000) {
res.status(400).json({ message: "Token expired" });
return;
}
const { viewerId, dataroomId, teamId } = payload;
if (!dataroomId) {
res.status(400).json({ message: "Dataroom ID is required" });
return;
}
if (req.method === "GET") {
return res.redirect(`/notification-preferences?token=${token}`);
}
// POST: update preferences
const ipAddress =
req.headers["x-forwarded-for"] || req.socket.remoteAddress || "127.0.0.1";
const { success, limit, reset, remaining } = await ratelimit(5, "1 m").limit(
`notification_prefs_${ipAddress}`,
);
res.setHeader("Retry-After", reset.toString());
res.setHeader("X-RateLimit-Limit", limit.toString());
res.setHeader("X-RateLimit-Remaining", remaining.toString());
res.setHeader("X-RateLimit-Reset", reset.toString());
if (!success) {
return res.status(429).json({ error: "Too many requests" });
}
// RFC 8058 one-click unsubscribe: email clients POST with
// body "List-Unsubscribe=One-Click" (form-urlencoded)
const isOneClick =
req.body?.["List-Unsubscribe"] === "One-Click" ||
(typeof req.body === "string" &&
req.body.includes("List-Unsubscribe=One-Click"));
try {
const frequency = isOneClick
? "disabled"
: UpdatePreferencesSchema.parse(req.body).frequency;
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId, teamId },
select: { notificationPreferences: true },
});
if (!viewer) {
return res.status(404).json({ message: "Viewer not found" });
}
const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
const rawPrefs =
typeof viewer.notificationPreferences === "object" &&
viewer.notificationPreferences !== null
? (viewer.notificationPreferences as Record<string, unknown>)
: {};
const base = {
...rawPrefs,
...(parsedPreferences.success ? parsedPreferences.data : {}),
};
const isDisabled = frequency === "disabled";
const updatedPreferences = {
...base,
dataroom: {
...(base.dataroom && typeof base.dataroom === "object"
? base.dataroom
: {}),
[dataroomId]: {
enabled: !isDisabled,
frequency: isDisabled ? "instant" : frequency,
},
},
};
await prisma.viewer.update({
where: { id: viewerId, teamId },
data: { notificationPreferences: updatedPreferences },
});
return res
.status(200)
.json({ message: "Notification preferences updated successfully." });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ message: "Invalid request body", errors: error.errors });
}
console.error("Failed to update notification preferences:", error);
return res.status(500).json({ message: "Internal server error" });
}
}
@@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { DefaultPermissionStrategy, ItemType } from "@prisma/client";
import slugify from "@sindresorhus/slugify";
import { safeSlugify } from "@/lib/utils";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
@@ -491,7 +491,7 @@ export default async function handle(
const basePath =
pathSegments.length > 0 ? "/" + pathSegments.join("/") + "/" : "/";
let childFolderPath = basePath + slugify(folderName);
let childFolderPath = basePath + safeSlugify(folderName);
while (counter <= MAX_RETRIES) {
const existingFolder = await prisma.dataroomFolder.findUnique({
@@ -504,7 +504,7 @@ export default async function handle(
});
if (!existingFolder) break;
folderName = `${name} (${counter})`;
childFolderPath = basePath + slugify(folderName);
childFolderPath = basePath + safeSlugify(folderName);
counter++;
}

Some files were not shown because too many files have changed in this diff Show More