Compare commits
87
Commits
chats
..
codex/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc02040ca | ||
|
|
1e65b74b8d | ||
|
|
a0f91e4b93 | ||
|
|
6ead6e6410 | ||
|
|
0df4ab5a74 | ||
|
|
afa7e0fa7d | ||
|
|
120a1b88fb | ||
|
|
86b0ee68b9 | ||
|
|
a44bae5edd | ||
|
|
6950dec056 | ||
|
|
6e9d6fe7d4 | ||
|
|
5e115f1896 | ||
|
|
4b557e909c | ||
|
|
8c3d677af8 | ||
|
|
6687d842db | ||
|
|
4a969ad9ad | ||
|
|
0b1acdf136 | ||
|
|
13a98ca7e5 | ||
|
|
015c44af80 | ||
|
|
1ebfd522a3 | ||
|
|
82b47a3e76 | ||
|
|
0f59913c14 | ||
|
|
d5c7a6d109 | ||
|
|
c394f623eb | ||
|
|
adde340bbd | ||
|
|
32275c6068 | ||
|
|
ef2f06f824 | ||
|
|
8d5568b807 | ||
|
|
eaabf16b2c | ||
|
|
3c7e4f93d0 | ||
|
|
3f95f1c118 | ||
|
|
996a40ceb9 | ||
|
|
6c1f5e261d | ||
|
|
dc0de8a8dd | ||
|
|
898313f5a5 | ||
|
|
a279392ba9 | ||
|
|
6951b2d92a | ||
|
|
76444e2992 | ||
|
|
47c256a53c | ||
|
|
a0570a18ca | ||
|
|
58d31905ef | ||
|
|
b8c6bb093f | ||
|
|
24b22dc1df | ||
|
|
c9b249c969 | ||
|
|
f677204bc2 | ||
|
|
71b243ad9b | ||
|
|
7216556de2 | ||
|
|
223d81adbf | ||
|
|
e19ae608d9 | ||
|
|
be9c7dedae | ||
|
|
9bdbe333ad | ||
|
|
f1a686e39d | ||
|
|
d3bd1802cd | ||
|
|
3ae1dbb678 | ||
|
|
58476317a5 | ||
|
|
c670f29273 | ||
|
|
996d5f0e21 | ||
|
|
0dd914a87d | ||
|
|
febb9322a7 | ||
|
|
661014c893 | ||
|
|
53d328c2d9 | ||
|
|
5f81333e6e | ||
|
|
47922d48d7 | ||
|
|
3794b3eeff | ||
|
|
8feb0e2d6e | ||
|
|
4e0969ab71 | ||
|
|
3c20959060 | ||
|
|
4145145b74 | ||
|
|
9d7b8bf8f2 | ||
|
|
2425a8933b | ||
|
|
d1a3603837 | ||
|
|
70beab2c5e | ||
|
|
e5dab063db | ||
|
|
c7c1f59649 | ||
|
|
7fd0d7bdfe | ||
|
|
cf1e783d8f | ||
|
|
5df098f630 | ||
|
|
6d7d88431b | ||
|
|
ce2b40daad | ||
|
|
2709049f68 | ||
|
|
e81d4118aa | ||
|
|
5d041f20f8 | ||
|
|
8a874e10e0 | ||
|
|
7c50bf6242 | ||
|
|
fb51399469 | ||
|
|
053394901c | ||
|
|
5e7ff9fa29 |
@@ -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" }), {
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
User-Agent: *
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /verify/
|
||||
Disallow: /auth/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const Gauge = ({
|
||||
xs: {
|
||||
width: "24",
|
||||
height: "24",
|
||||
textSize: "text-xs",
|
||||
textSize: "text-[10px]",
|
||||
},
|
||||
small: {
|
||||
width: "36",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "Add
|
||||
Document" 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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+3
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))),
|
||||
]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -48,4 +48,5 @@ export const newId = new IdGenerator({
|
||||
token: "pmk",
|
||||
clickEvent: "click",
|
||||
preset: "preset",
|
||||
pending: "pending", // for pending uploads
|
||||
}).id;
|
||||
|
||||
+60
-9
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2630
-1756
File diff suppressed because it is too large
Load Diff
+24
-23
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function handle(
|
||||
showBanner: true,
|
||||
enableWatermark: true,
|
||||
watermarkConfig: true,
|
||||
enableConversation: true,
|
||||
groupId: true,
|
||||
permissionGroupId: true,
|
||||
audienceType: true,
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user