mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.
Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
dataroom-invitations,access-notifications,security,conversions}/` →
`features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
unlimited profile (no users/links/documents/domains/datarooms cap,
conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
`@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
retention routes now compile against `lib/billing/cancellation.ts`
stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
`lib/billing/paused.ts` always report active. `CancellationModal` is
a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
SCIM, AI chat, workflows, FAQ, link-upload).
Build: `npm run build` green.
1120 lines
34 KiB
TypeScript
1120 lines
34 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
import { reportDeniedAccessAttempt } from "@/features/access-notifications";
|
|
import { getTeamStorageConfigById } from "@/features/storage/config";
|
|
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
|
import { ItemType, LinkAudienceType } from "@prisma/client";
|
|
import { ipAddress, waitUntil } from "@vercel/functions";
|
|
import { getServerSession } from "next-auth";
|
|
|
|
import { hashToken } from "@/lib/api/auth/token";
|
|
import {
|
|
DataroomSession,
|
|
createDataroomSession,
|
|
} from "@/lib/auth/dataroom-auth";
|
|
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
|
|
import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
|
|
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
|
|
import { getFile } from "@/lib/files/get-file";
|
|
import { newId } from "@/lib/id-helper";
|
|
import {
|
|
notifyDataroomAccess,
|
|
notifyDocumentView,
|
|
} from "@/lib/integrations/slack/events";
|
|
import prisma from "@/lib/prisma";
|
|
import { ratelimit } from "@/lib/redis";
|
|
import { parseSheet } from "@/lib/sheet";
|
|
import { recordLinkView } from "@/lib/tracking/record-link-view";
|
|
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
|
|
import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils";
|
|
import { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
|
|
import { generateOTP } from "@/lib/utils/generate-otp";
|
|
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
|
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
|
|
import { validateEmail } from "@/lib/utils/validate-email";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json();
|
|
|
|
const {
|
|
linkId,
|
|
documentId,
|
|
dataroomId,
|
|
userId,
|
|
documentVersionId,
|
|
documentName,
|
|
hasPages,
|
|
ownerId,
|
|
linkType,
|
|
dataroomViewId,
|
|
viewType,
|
|
groupId,
|
|
...data
|
|
} = body as {
|
|
linkId: string;
|
|
documentId: string | undefined;
|
|
dataroomId: string;
|
|
userId: string | null;
|
|
documentVersionId: string | undefined;
|
|
documentName: string | undefined;
|
|
hasPages: boolean | undefined;
|
|
ownerId: string | null;
|
|
linkType: string;
|
|
dataroomViewId?: string;
|
|
viewType: "DATAROOM_VIEW" | "DOCUMENT_VIEW";
|
|
groupId?: string;
|
|
};
|
|
|
|
const { email, password, name, hasConfirmedAgreement } = data as {
|
|
email: string;
|
|
password: string;
|
|
name?: string;
|
|
hasConfirmedAgreement?: boolean;
|
|
};
|
|
|
|
// Add customFields to the data extraction
|
|
const { customFields } = data as {
|
|
customFields?: { [key: string]: string };
|
|
};
|
|
|
|
// INFO: for using the advanced excel viewer
|
|
let { useAdvancedExcelViewer } = data as {
|
|
useAdvancedExcelViewer: boolean;
|
|
};
|
|
|
|
// previewToken is used to determine if the view is a preview and therefore should not be recorded
|
|
const { previewToken } = data as {
|
|
previewToken?: string;
|
|
};
|
|
|
|
// Email Verification Data
|
|
const { code, token, verifiedEmail } = data as {
|
|
code?: string;
|
|
token?: string;
|
|
verifiedEmail?: string;
|
|
};
|
|
|
|
// Fetch the link to verify the settings
|
|
const link = await prisma.link.findUnique({
|
|
where: {
|
|
id: linkId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
documentId: true,
|
|
dataroomId: true,
|
|
emailProtected: true,
|
|
enableNotification: true,
|
|
emailAuthenticated: true,
|
|
password: true,
|
|
domainSlug: true,
|
|
isArchived: true,
|
|
deletedAt: true,
|
|
slug: true,
|
|
domainId: true,
|
|
allowList: true,
|
|
denyList: true,
|
|
enableAgreement: true,
|
|
agreementId: true,
|
|
enableWatermark: true,
|
|
watermarkConfig: true,
|
|
groupId: true,
|
|
permissionGroupId: true,
|
|
audienceType: true,
|
|
allowDownload: true,
|
|
enableConversation: true,
|
|
teamId: true,
|
|
team: {
|
|
select: {
|
|
plan: true,
|
|
globalBlockList: true,
|
|
agentsEnabled: true,
|
|
pauseStartsAt: true,
|
|
},
|
|
},
|
|
customFields: {
|
|
select: {
|
|
identifier: true,
|
|
label: true,
|
|
},
|
|
},
|
|
enableUpload: true,
|
|
dataroom: {
|
|
select: {
|
|
agentsEnabled: true,
|
|
name: true,
|
|
},
|
|
},
|
|
visitorGroups: {
|
|
select: {
|
|
visitorGroup: {
|
|
select: {
|
|
emails: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!link) {
|
|
return NextResponse.json({ message: "Link not found." }, { status: 404 });
|
|
}
|
|
|
|
if (link.isArchived) {
|
|
return NextResponse.json(
|
|
{ message: "Link is no longer available." },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
if (link.deletedAt) {
|
|
return NextResponse.json(
|
|
{ message: "Link has been deleted." },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
let isEmailVerified: boolean = false;
|
|
let hashedVerificationToken: string | null = null;
|
|
// Check if the user is part of the team and therefore skip verification steps
|
|
let isTeamMember: boolean = false;
|
|
let isPreview: boolean = false;
|
|
if (userId && previewToken) {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session) {
|
|
return NextResponse.json(
|
|
{ message: "You need to be logged in to preview the link." },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const sessionUserId = (session.user as CustomUser).id;
|
|
const teamMembership = await prisma.userTeam.findUnique({
|
|
where: {
|
|
userId_teamId: {
|
|
userId: sessionUserId,
|
|
teamId: link.teamId!,
|
|
},
|
|
},
|
|
});
|
|
if (teamMembership) {
|
|
isTeamMember = true;
|
|
isPreview = true;
|
|
isEmailVerified = true;
|
|
}
|
|
}
|
|
|
|
// Check if there's a valid preview session
|
|
let previewSession: PreviewSession | null = null;
|
|
if (!isPreview && previewToken) {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session) {
|
|
return NextResponse.json(
|
|
{ message: "You need to be logged in to preview the link." },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
previewSession = await verifyPreviewSession(
|
|
previewToken,
|
|
(session.user as CustomUser).id,
|
|
linkId,
|
|
);
|
|
|
|
if (!previewSession) {
|
|
return NextResponse.json(
|
|
{
|
|
message: "Preview session expired or invalid. Request a new one.",
|
|
resetPreview: true,
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
isPreview = true;
|
|
}
|
|
|
|
let dataroomSession: DataroomSession | null = null;
|
|
if (!isPreview) {
|
|
dataroomSession = await verifyDataroomSession(
|
|
request,
|
|
linkId,
|
|
link.dataroomId!,
|
|
);
|
|
|
|
|
|
// If we have a dataroom session, use its verified status
|
|
if (dataroomSession) {
|
|
isEmailVerified = dataroomSession.verified;
|
|
}
|
|
}
|
|
|
|
// If there is no session, then we need to check if the link is protected and enforce the checks
|
|
if (!dataroomSession && !isPreview) {
|
|
// Check if email is required for visiting the link
|
|
if (link.emailProtected) {
|
|
if (!email || email.trim() === "") {
|
|
return NextResponse.json(
|
|
{ message: "Email is required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// validate email
|
|
if (!validateEmail(email)) {
|
|
return NextResponse.json(
|
|
{ message: "Invalid email address." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if password is required for visiting the link
|
|
if (link.password) {
|
|
if (!password || password.trim() === "") {
|
|
return NextResponse.json(
|
|
{ message: "Password is required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
let isPasswordValid: boolean = false;
|
|
const textParts: string[] = link.password.split(":");
|
|
if (!textParts || textParts.length !== 2) {
|
|
isPasswordValid = await checkPassword(password, link.password);
|
|
} else {
|
|
const decryptedPassword = decryptEncrpytedPassword(link.password);
|
|
isPasswordValid = decryptedPassword === password;
|
|
}
|
|
|
|
if (!isPasswordValid) {
|
|
return NextResponse.json(
|
|
{ message: "Invalid password." },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if agreement is required for visiting the link
|
|
if (link.enableAgreement && !hasConfirmedAgreement) {
|
|
return NextResponse.json(
|
|
{ message: "Agreement to NDA is required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Check global block list first - this overrides all other access controls
|
|
const globalBlockCheck = checkGlobalBlockList(
|
|
email,
|
|
link.team?.globalBlockList,
|
|
);
|
|
if (globalBlockCheck.error) {
|
|
return NextResponse.json(
|
|
{ message: globalBlockCheck.error },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (globalBlockCheck.isBlocked) {
|
|
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
|
|
|
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
|
}
|
|
|
|
// Build combined allow list from individual emails + visitor groups
|
|
const visitorGroupEmails =
|
|
link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];
|
|
const combinedAllowList = [
|
|
...(link.allowList || []),
|
|
...visitorGroupEmails,
|
|
];
|
|
|
|
// Check if email is allowed to visit the link
|
|
if (combinedAllowList.length > 0) {
|
|
// Determine if the email or its domain is allowed
|
|
const isAllowed = combinedAllowList.some((allowed) =>
|
|
isEmailMatched(email, allowed),
|
|
);
|
|
|
|
// Deny access if the email is not allowed
|
|
if (!isAllowed) {
|
|
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
|
|
|
return NextResponse.json(
|
|
{ message: "Unauthorized access" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if email is denied to visit the link
|
|
if (link.denyList && link.denyList.length > 0) {
|
|
// Determine if the email or its domain is denied
|
|
const isDenied = link.denyList.some((denied) =>
|
|
isEmailMatched(email, denied),
|
|
);
|
|
|
|
// Deny access if the email is denied
|
|
if (isDenied) {
|
|
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
|
|
|
|
return NextResponse.json(
|
|
{ message: "Unauthorized access" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if group is allowed to visit the link
|
|
if (link.audienceType === LinkAudienceType.GROUP && link.groupId) {
|
|
const group = await prisma.viewerGroup.findUnique({
|
|
where: { id: link.groupId },
|
|
select: {
|
|
members: { include: { viewer: { select: { email: true } } } },
|
|
domains: true,
|
|
allowAll: true,
|
|
},
|
|
});
|
|
|
|
if (!group) {
|
|
return NextResponse.json(
|
|
{ message: "Group not found." },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
// Check if all emails are allowed
|
|
if (group.allowAll) {
|
|
// Allow access
|
|
} else {
|
|
// Check individual membership
|
|
const isMember = group.members.some(
|
|
(member) => member.viewer.email === email,
|
|
);
|
|
|
|
// Extract domain from email
|
|
const emailDomain = extractEmailDomain(email);
|
|
// Check domain access
|
|
const hasDomainAccess = emailDomain
|
|
? group.domains.some((domain) => domain === emailDomain)
|
|
: false;
|
|
|
|
if (!isMember && !hasDomainAccess) {
|
|
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
|
return NextResponse.json(
|
|
{ message: "Unauthorized access" },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request OTP Code for email verification if
|
|
// 1) email verification is required and
|
|
// 2) code is not provided or token not provided
|
|
if (link.emailAuthenticated && !code && !token) {
|
|
const ipAddressValue = ipAddress(request);
|
|
|
|
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
|
|
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
|
`send-otp:${linkId}:${email}`,
|
|
);
|
|
if (!emailLimitSuccess) {
|
|
return NextResponse.json(
|
|
{
|
|
message:
|
|
"Please wait before requesting another code. Try again in 30 seconds.",
|
|
},
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
|
|
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
|
|
const { success } = await ratelimit(10, "1 m").limit(
|
|
`send-otp:${ipAddressValue}`,
|
|
);
|
|
if (!success) {
|
|
return NextResponse.json(
|
|
{ message: "Too many requests. Please try again later." },
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
|
|
await prisma.verificationToken.deleteMany({
|
|
where: {
|
|
identifier: `otp:${linkId}:${email}`,
|
|
},
|
|
});
|
|
|
|
const otpCode = generateOTP();
|
|
const expiresAt = new Date();
|
|
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // token expires at 10 minutes
|
|
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
token: otpCode,
|
|
identifier: `otp:${linkId}:${email}`,
|
|
expires: expiresAt,
|
|
},
|
|
});
|
|
|
|
waitUntil(sendOtpVerificationEmail(email, otpCode, true, link.teamId!));
|
|
return NextResponse.json(
|
|
{
|
|
type: "email-verification",
|
|
message: "Verification email sent.",
|
|
},
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
|
|
if (link.emailAuthenticated && code) {
|
|
const ipAddressValue = ipAddress(request);
|
|
const { success } = await ratelimit(10, "1 m").limit(
|
|
`verify-otp:${ipAddressValue}`,
|
|
);
|
|
if (!success) {
|
|
return NextResponse.json(
|
|
{ message: "Too many requests. Please try again later." },
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
|
|
// Check if the OTP code is valid
|
|
const verification = await prisma.verificationToken.findUnique({
|
|
where: {
|
|
token: code,
|
|
identifier: `otp:${linkId}:${email}`,
|
|
},
|
|
});
|
|
|
|
if (!verification) {
|
|
return NextResponse.json(
|
|
{
|
|
message: "Unauthorized access. Request new access.",
|
|
resetVerification: true,
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
// Check the OTP code's expiration date
|
|
if (Date.now() > verification.expires.getTime()) {
|
|
await prisma.verificationToken.delete({
|
|
where: {
|
|
token: code,
|
|
},
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
message: "Access expired. Request new access.",
|
|
resetVerification: true,
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
// delete the OTP code after verification
|
|
await prisma.verificationToken.delete({
|
|
where: {
|
|
token: code,
|
|
},
|
|
});
|
|
|
|
// Create a email verification token for repeat access
|
|
const token = newId("email");
|
|
hashedVerificationToken = hashToken(token);
|
|
const tokenExpiresAt = new Date();
|
|
tokenExpiresAt.setHours(tokenExpiresAt.getHours() + 23); // token expires at 23 hours
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
token: hashedVerificationToken,
|
|
identifier: `link-verification:${linkId}:${link.teamId}:${email}`,
|
|
expires: tokenExpiresAt,
|
|
},
|
|
});
|
|
|
|
isEmailVerified = true;
|
|
}
|
|
|
|
if (link.emailAuthenticated && token) {
|
|
const ipAddressValue = ipAddress(request);
|
|
const { success } = await ratelimit(10, "1 m").limit(
|
|
`verify-email:${ipAddressValue}`,
|
|
);
|
|
if (!success) {
|
|
return NextResponse.json(
|
|
{ message: "Too many requests. Please try again later." },
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
|
|
// Check if the long-term verification token is valid
|
|
const verification = await prisma.verificationToken.findUnique({
|
|
where: {
|
|
token: token,
|
|
identifier: `link-verification:${linkId}:${link.teamId}:${email}`,
|
|
},
|
|
});
|
|
|
|
if (!verification) {
|
|
return NextResponse.json(
|
|
{
|
|
message: "Unauthorized access. Request new access.",
|
|
resetVerification: true,
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
// Check the long-term verification token's expiration date
|
|
if (Date.now() > verification.expires.getTime()) {
|
|
// delete the long-term verification token after verification
|
|
await prisma.verificationToken.delete({
|
|
where: {
|
|
token: token,
|
|
},
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
message: "Access expired. Request new access.",
|
|
resetVerification: true,
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
isEmailVerified = true;
|
|
}
|
|
|
|
}
|
|
|
|
let viewer: { id: string; email: string; verified: boolean } | null = null;
|
|
if (!isPreview) {
|
|
if (!dataroomSession) {
|
|
if (email) {
|
|
// find or create a viewer
|
|
console.time("find-viewer");
|
|
viewer = await prisma.viewer.findUnique({
|
|
where: {
|
|
teamId_email: {
|
|
teamId: link.teamId!,
|
|
email: email,
|
|
},
|
|
},
|
|
select: { id: true, email: true, verified: true },
|
|
});
|
|
console.timeEnd("find-viewer");
|
|
|
|
if (!viewer) {
|
|
console.time("create-viewer");
|
|
viewer = await prisma.viewer.create({
|
|
data: {
|
|
email: email,
|
|
verified: isEmailVerified,
|
|
teamId: link.teamId!,
|
|
},
|
|
select: { id: true, email: true, verified: true },
|
|
});
|
|
console.timeEnd("create-viewer");
|
|
}
|
|
}
|
|
} else {
|
|
if (dataroomSession.viewerId) {
|
|
viewer = await prisma.viewer.findUnique({
|
|
where: { id: dataroomSession.viewerId, teamId: link.teamId! },
|
|
select: { id: true, email: true, verified: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (viewer && !viewer.verified && isEmailVerified) {
|
|
await prisma.viewer.update({
|
|
where: { id: viewer.id },
|
|
data: { verified: isEmailVerified },
|
|
});
|
|
// Update the viewer object to reflect the new verified status
|
|
viewer.verified = isEmailVerified;
|
|
}
|
|
}
|
|
|
|
// Common fields for the view object shared between DATAROOM_VIEW and DOCUMENT_VIEW
|
|
const viewFields = {
|
|
linkId: linkId,
|
|
viewerEmail: viewer?.email ?? email,
|
|
viewerName: name,
|
|
verified: isEmailVerified,
|
|
dataroomId: link.dataroomId,
|
|
viewerId: viewer?.id ?? undefined,
|
|
teamId: link.teamId,
|
|
...(link.enableAgreement &&
|
|
link.agreementId &&
|
|
hasConfirmedAgreement && {
|
|
agreementResponse: {
|
|
create: {
|
|
agreementId: link.agreementId,
|
|
},
|
|
},
|
|
}),
|
|
...(link.audienceType === LinkAudienceType.GROUP &&
|
|
link.groupId && {
|
|
groupId: link.groupId,
|
|
}),
|
|
...(customFields &&
|
|
link.customFields.length > 0 && {
|
|
customFieldResponse: {
|
|
create: {
|
|
data: link.customFields.map((field) => ({
|
|
identifier: field.identifier,
|
|
label: field.label,
|
|
response: customFields[field.identifier] || "",
|
|
})),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
|
|
const isPaused =
|
|
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
|
|
? true
|
|
: false;
|
|
|
|
// ** DATAROOM_VIEW **
|
|
if (viewType === "DATAROOM_VIEW") {
|
|
try {
|
|
let newDataroomView: { id: string } | null = null;
|
|
if (!isPreview) {
|
|
if (!dataroomSession) {
|
|
console.time("create-view");
|
|
newDataroomView = await prisma.view.create({
|
|
data: { ...viewFields, viewType: "DATAROOM_VIEW" },
|
|
select: { id: true },
|
|
});
|
|
console.timeEnd("create-view");
|
|
}
|
|
}
|
|
|
|
// Send events in the background to avoid blocking the response
|
|
if (newDataroomView) {
|
|
waitUntil(
|
|
// Record link view in Tinybird
|
|
recordLinkView({
|
|
req: request,
|
|
clickId: newId("linkView"),
|
|
viewId: newDataroomView.id,
|
|
linkId,
|
|
dataroomId: link.dataroomId!,
|
|
teamId: link.teamId!,
|
|
enableNotification: link.enableNotification,
|
|
isPaused,
|
|
}),
|
|
);
|
|
|
|
if (link.teamId && !isPreview) {
|
|
waitUntil(
|
|
(async () => {
|
|
try {
|
|
await notifyDataroomAccess({
|
|
teamId: link.teamId!,
|
|
dataroomId: link.dataroomId!,
|
|
linkId,
|
|
viewerEmail: verifiedEmail ?? email,
|
|
viewerId: viewer?.id,
|
|
teamIsPaused: isPaused,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error sending Slack notification:", error);
|
|
}
|
|
})(),
|
|
);
|
|
}
|
|
}
|
|
|
|
const dataroomViewId =
|
|
newDataroomView?.id ?? dataroomSession?.viewId ?? undefined;
|
|
|
|
const returnObject = {
|
|
message: "Dataroom View recorded",
|
|
viewId: dataroomViewId,
|
|
isPreview: isPreview ? true : undefined,
|
|
file: undefined,
|
|
pages: undefined,
|
|
notionData: undefined,
|
|
verificationToken: hashedVerificationToken,
|
|
viewerId: viewer?.id,
|
|
conversationsEnabled: link.enableConversation,
|
|
enableVisitorUpload: link.enableUpload,
|
|
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
|
|
dataroomName: link.dataroom?.name,
|
|
...(isTeamMember && { isTeamMember: true }),
|
|
};
|
|
|
|
const response = NextResponse.json(returnObject, { status: 200 });
|
|
|
|
// Create a dataroom session token if a dataroom session doesn't exist yet
|
|
if (!dataroomSession && !isPreview) {
|
|
const newDataroomSession = await createDataroomSession(
|
|
link.dataroomId!,
|
|
linkId,
|
|
newDataroomView?.id!,
|
|
ipAddress(request) ?? LOCALHOST_IP,
|
|
isEmailVerified,
|
|
viewer?.id,
|
|
);
|
|
|
|
let basePath = `/view/${linkId}`;
|
|
const cookieId = `pm_drs_${linkId}`;
|
|
let flagCookieId = `pm_drs_flag_${linkId}`;
|
|
|
|
if (link.domainId) {
|
|
basePath = `/${link.slug}`;
|
|
flagCookieId = `pm_drs_flag_${link.slug}`;
|
|
}
|
|
|
|
response.cookies.set(cookieId, newDataroomSession?.token, {
|
|
path: "/",
|
|
expires: new Date(newDataroomSession?.expiresAt),
|
|
httpOnly: true,
|
|
sameSite: "strict",
|
|
});
|
|
response.cookies.set(flagCookieId, "true", {
|
|
path: basePath,
|
|
expires: new Date(newDataroomSession?.expiresAt),
|
|
sameSite: "strict",
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
log({
|
|
message: `Failed to record view for dataroom link: ${linkId}. \n\n ${error}`,
|
|
type: "error",
|
|
mention: true,
|
|
});
|
|
return NextResponse.json(
|
|
{ message: (error as Error).message },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// ** DOCUMENT_VIEW **
|
|
try {
|
|
let newView: { id: string } | null = null;
|
|
let dataroomView: { id: string } | null = null;
|
|
if (!isPreview) {
|
|
console.time("create-view");
|
|
|
|
// if dataroomSession is not present, create a dataroom view first
|
|
if (!dataroomSession) {
|
|
dataroomView = await prisma.view.create({
|
|
data: { ...viewFields, viewType: "DATAROOM_VIEW" },
|
|
select: { id: true },
|
|
});
|
|
|
|
waitUntil(
|
|
// Record link view in Tinybird
|
|
recordLinkView({
|
|
req: request,
|
|
clickId: newId("linkView"),
|
|
viewId: dataroomView.id,
|
|
linkId,
|
|
dataroomId: link.dataroomId!,
|
|
teamId: link.teamId!,
|
|
enableNotification: link.enableNotification,
|
|
isPaused,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// create the document view
|
|
newView = await prisma.view.create({
|
|
data: {
|
|
...viewFields,
|
|
documentId: documentId,
|
|
dataroomViewId:
|
|
dataroomSession?.viewId ?? dataroomView?.id ?? dataroomViewId,
|
|
viewType: "DOCUMENT_VIEW",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
console.timeEnd("create-view");
|
|
// Only send Slack notifications for non-preview views
|
|
if (link.teamId && !isPreview) {
|
|
waitUntil(
|
|
(async () => {
|
|
try {
|
|
await notifyDocumentView({
|
|
teamId: link.teamId!,
|
|
documentId,
|
|
dataroomId: link.dataroomId!,
|
|
linkId,
|
|
viewerEmail: verifiedEmail ?? email,
|
|
viewerId: viewer?.id,
|
|
teamIsPaused: isPaused,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error sending Slack notification:", error);
|
|
}
|
|
})(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// if document version has pages, then return pages
|
|
// otherwise, return file from document version
|
|
let documentPages, documentVersion;
|
|
let sheetData;
|
|
|
|
if (hasPages) {
|
|
// get pages from document version
|
|
console.time("get-pages");
|
|
documentPages = await prisma.documentPage.findMany({
|
|
where: { versionId: documentVersionId },
|
|
orderBy: { pageNumber: "asc" },
|
|
select: {
|
|
file: true,
|
|
storageType: true,
|
|
pageNumber: true,
|
|
embeddedLinks: !link.team?.plan.includes("free"),
|
|
pageLinks: !link.team?.plan.includes("free"),
|
|
metadata: true,
|
|
},
|
|
});
|
|
|
|
documentPages = await Promise.all(
|
|
documentPages.map(async (page) => {
|
|
const { storageType, ...otherPage } = page;
|
|
return {
|
|
...otherPage,
|
|
file: await getFile({ data: page.file, type: storageType }),
|
|
};
|
|
}),
|
|
);
|
|
|
|
console.timeEnd("get-pages");
|
|
} else {
|
|
// get file from document version
|
|
console.time("get-file");
|
|
documentVersion = await prisma.documentVersion.findUnique({
|
|
where: { id: documentVersionId },
|
|
select: {
|
|
file: true,
|
|
storageType: true,
|
|
type: true,
|
|
},
|
|
});
|
|
|
|
if (!documentVersion) {
|
|
return NextResponse.json(
|
|
{ message: "Document version not found." },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
if (
|
|
documentVersion.type === "pdf" ||
|
|
documentVersion.type === "image" ||
|
|
documentVersion.type === "video"
|
|
) {
|
|
documentVersion.file = await getFile({
|
|
data: documentVersion.file,
|
|
type: documentVersion.storageType,
|
|
});
|
|
}
|
|
// For link documents, the file is already a URL, no processing needed
|
|
if (documentVersion.type === "sheet") {
|
|
const document = await prisma.document.findUnique({
|
|
where: { id: documentId },
|
|
select: { advancedExcelEnabled: true },
|
|
});
|
|
useAdvancedExcelViewer = document?.advancedExcelEnabled ?? false;
|
|
|
|
if (useAdvancedExcelViewer) {
|
|
if (documentVersion.file.includes("https://")) {
|
|
documentVersion.file = documentVersion.file;
|
|
} else {
|
|
// Get team-specific storage config for advanced distribution host
|
|
const storageConfig = await getTeamStorageConfigById(
|
|
link.teamId!,
|
|
);
|
|
documentVersion.file = `https://${storageConfig.advancedDistributionHost}/${documentVersion.file}`;
|
|
}
|
|
} else {
|
|
const fileUrl = await getFile({
|
|
data: documentVersion.file,
|
|
type: documentVersion.storageType,
|
|
});
|
|
|
|
const data = await parseSheet({ fileUrl });
|
|
sheetData = data;
|
|
}
|
|
}
|
|
console.timeEnd("get-file");
|
|
}
|
|
|
|
// check if viewer can download the document based on group permissions
|
|
let canDownload: boolean = link.allowDownload ?? false;
|
|
const effectiveGroupId = link.groupId || link.permissionGroupId;
|
|
|
|
if (
|
|
link.allowDownload &&
|
|
(link.audienceType === LinkAudienceType.GROUP ||
|
|
link.permissionGroupId) &&
|
|
effectiveGroupId &&
|
|
documentId &&
|
|
link.dataroomId
|
|
) {
|
|
const dataroomDocument = await prisma.dataroomDocument.findUnique({
|
|
where: {
|
|
dataroomId_documentId: {
|
|
dataroomId: link.dataroomId,
|
|
documentId: documentId,
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (!dataroomDocument) {
|
|
canDownload = false;
|
|
} else {
|
|
if (link.groupId) {
|
|
// This is a ViewerGroup (legacy behavior)
|
|
const groupDocumentPermission =
|
|
await prisma.viewerGroupAccessControls.findUnique({
|
|
where: {
|
|
groupId_itemId: {
|
|
groupId: link.groupId,
|
|
itemId: dataroomDocument.id,
|
|
},
|
|
itemType: ItemType.DATAROOM_DOCUMENT,
|
|
},
|
|
select: { canDownload: true },
|
|
});
|
|
canDownload = groupDocumentPermission?.canDownload ?? false;
|
|
} else if (link.permissionGroupId) {
|
|
// This is a PermissionGroup (new behavior)
|
|
const permissionGroupDocumentPermission =
|
|
await prisma.permissionGroupAccessControls.findUnique({
|
|
where: {
|
|
groupId_itemId: {
|
|
groupId: link.permissionGroupId,
|
|
itemId: dataroomDocument.id,
|
|
},
|
|
itemType: ItemType.DATAROOM_DOCUMENT,
|
|
},
|
|
select: { canDownload: true },
|
|
});
|
|
canDownload =
|
|
permissionGroupDocumentPermission?.canDownload ?? false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const returnObject = {
|
|
message: "View recorded",
|
|
viewId: !isPreview && newView ? newView.id : undefined,
|
|
isPreview: isPreview ? true : undefined,
|
|
file:
|
|
(documentVersion &&
|
|
(documentVersion.type === "pdf" ||
|
|
documentVersion.type === "image" ||
|
|
documentVersion.type === "zip" ||
|
|
documentVersion.type === "video" ||
|
|
documentVersion.type === "link")) ||
|
|
(documentVersion && useAdvancedExcelViewer)
|
|
? documentVersion.file
|
|
: undefined,
|
|
pages: documentPages ? documentPages : undefined,
|
|
notionData: undefined,
|
|
sheetData:
|
|
documentVersion &&
|
|
documentVersion.type === "sheet" &&
|
|
!useAdvancedExcelViewer
|
|
? sheetData
|
|
: undefined,
|
|
fileType: documentVersion
|
|
? documentVersion.type
|
|
: documentPages
|
|
? "pdf"
|
|
: undefined,
|
|
watermarkConfig: link.enableWatermark
|
|
? link.watermarkConfig
|
|
: undefined,
|
|
viewerEmail: viewer?.email ?? email ?? verifiedEmail ?? null,
|
|
ipAddress:
|
|
link.enableWatermark &&
|
|
link.watermarkConfig &&
|
|
WatermarkConfigSchema.parse(link.watermarkConfig).text.includes(
|
|
"{{ipAddress}}",
|
|
)
|
|
? (ipAddress(request) ?? LOCALHOST_IP)
|
|
: undefined,
|
|
useAdvancedExcelViewer:
|
|
documentVersion &&
|
|
documentVersion.type === "sheet" &&
|
|
useAdvancedExcelViewer
|
|
? useAdvancedExcelViewer
|
|
: undefined,
|
|
canDownload: canDownload,
|
|
viewerId: viewer?.id,
|
|
conversationsEnabled: link.enableConversation,
|
|
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
|
|
dataroomName: link.dataroom?.name,
|
|
...(isTeamMember && { isTeamMember: true }),
|
|
};
|
|
|
|
const response = NextResponse.json(returnObject, { status: 200 });
|
|
|
|
// Create a dataroom session token if a dataroom session doesn't exist yet
|
|
if (!dataroomSession && !isPreview) {
|
|
const newDataroomSession = await createDataroomSession(
|
|
link.dataroomId!,
|
|
linkId,
|
|
dataroomView?.id!,
|
|
ipAddress(request) ?? LOCALHOST_IP,
|
|
isEmailVerified,
|
|
viewer?.id,
|
|
);
|
|
|
|
let basePath = `/view/${linkId}`;
|
|
const cookieId = `pm_drs_${linkId}`;
|
|
let flagCookieId = `pm_drs_flag_${linkId}`;
|
|
if (link.domainId) {
|
|
basePath = `/${link.slug}`;
|
|
flagCookieId = `pm_drs_flag_${link.slug}`;
|
|
}
|
|
|
|
response.cookies.set(cookieId, newDataroomSession?.token, {
|
|
path: "/",
|
|
expires: new Date(newDataroomSession?.expiresAt),
|
|
httpOnly: true,
|
|
sameSite: "strict",
|
|
});
|
|
response.cookies.set(flagCookieId, "true", {
|
|
path: basePath,
|
|
expires: new Date(newDataroomSession?.expiresAt),
|
|
sameSite: "strict",
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
log({
|
|
message: `Failed to record view for dataroom document ${linkId}. \n\n ${error}`,
|
|
type: "error",
|
|
mention: true,
|
|
});
|
|
return NextResponse.json(
|
|
{ message: (error as Error).message },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
return NextResponse.json(
|
|
{ error: "Internal Server Error" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|