Files
zooqueenandHanzo Dev 9a7ba68fe3 sqlite: stop calling createMany with an argument it rejects
`skipDuplicates` is not merely ignored on sqlite — Prisma rejects the whole
call with "Unknown argument `skipDuplicates`". Verified against a database
built from this schema, so all seventeen call sites were throwing at runtime:
applying dataroom and folder permissions, adding group members, saving link
visitor groups and tags, bulk and folder downloads, and the year-in-review
queue.

`insert()` keeps the behaviour those sites asked for. The batch insert is
still one statement; only a uniqueness collision falls back to row-at-a-time,
skipping what is already there. That fallback is idempotent, so it is correct
even if the batch wrote some rows before colliding, and a caught P2002 leaves
an open transaction usable — checked, since four of these run inside one.
Anything that is not a duplicate still throws.

The nested customFields write just drops the argument: CustomField has no
unique constraint, so nothing could ever have been skipped there.

99 errors -> 21, no file worse than it was.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 11:52:57 -07:00

628 lines
18 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from "next";
import { Brand, DataroomBrand, LinkAudienceType } from "@prisma/client";
import { customAlphabet } from "nanoid";
import { getServerSession } from "next-auth/next";
import {
fetchDataroomLinkData,
fetchDocumentLinkData,
} from "@/lib/api/links/link-data";
import prisma from "@/lib/prisma";
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
import {
decryptEncrpytedPassword,
generateEncrpytedPassword,
} from "@/lib/utils";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
import { DomainObject } from "..";
import { authOptions } from "../../auth/[...nextauth]";
import { insert } from "@/lib/insert";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "GET") {
// GET /api/links/:id
const { id } = req.query as { id: string };
try {
console.time("get-link");
const link = await prisma.link.findUnique({
where: {
id: id,
},
select: {
id: true,
expiresAt: true,
emailProtected: true,
emailAuthenticated: true,
allowDownload: true,
enableFeedback: true,
enableScreenshotProtection: true,
password: true,
isArchived: true,
deletedAt: true,
enableIndexFile: true,
enableCustomMetatag: true,
metaTitle: true,
metaDescription: true,
metaImage: true,
metaFavicon: true,
welcomeMessage: true,
enableQuestion: true,
linkType: true,
feedback: {
select: {
id: true,
data: true,
},
},
enableAgreement: true,
agreement: true,
showBanner: true,
enableWatermark: true,
watermarkConfig: true,
groupId: true,
permissionGroupId: true,
audienceType: true,
dataroomId: true,
teamId: true,
team: {
select: {
plan: true,
globalBlockList: true,
},
},
customFields: {
select: {
id: true,
type: true,
identifier: true,
label: true,
placeholder: true,
required: true,
disabled: true,
orderIndex: true,
},
orderBy: {
orderIndex: "asc",
},
},
},
});
console.timeEnd("get-link");
if (!link) {
return res.status(404).json({ error: "Link not found" });
}
if (link.deletedAt) {
return res.status(404).json({ error: "Link has been deleted" });
}
if (link.isArchived) {
return res.status(404).json({ error: "Link is archived" });
}
const { email } = req.query as { email?: string };
const globalBlockCheck = checkGlobalBlockList(
email,
link.team?.globalBlockList,
);
if (globalBlockCheck.error) {
return res.status(400).json({ message: globalBlockCheck.error });
}
if (globalBlockCheck.isBlocked) {
return res.status(403).json({ message: "Access denied" });
}
const linkType = link.linkType;
// Handle workflow links separately
if (linkType === "WORKFLOW_LINK") {
// For workflow links, fetch brand if available
let brand: Partial<Brand> | null = null;
if (link.teamId) {
const teamBrand = await prisma.brand.findUnique({
where: { teamId: link.teamId },
select: {
logo: true,
brandColor: true,
accentColor: true,
},
});
brand = teamBrand;
}
return res.status(200).json({ linkType, brand });
}
let brand: Partial<Brand> | Partial<DataroomBrand> | null = null;
let linkData: any;
if (linkType === "DOCUMENT_LINK") {
console.time("get-document-link-data");
const data = await fetchDocumentLinkData({
linkId: id,
teamId: link.teamId!,
});
linkData = data.linkData;
brand = data.brand;
console.timeEnd("get-document-link-data");
} else if (linkType === "DATAROOM_LINK") {
console.time("get-dataroom-link-data");
const data = await fetchDataroomLinkData({
linkId: id,
dataroomId: link.dataroomId,
teamId: link.teamId!,
permissionGroupId: link.permissionGroupId || undefined,
...(link.audienceType === LinkAudienceType.GROUP &&
link.groupId && {
groupId: link.groupId,
}),
});
linkData = data.linkData;
brand = data.brand;
// Include access controls in the link data for the frontend
linkData.accessControls = data.accessControls;
console.timeEnd("get-dataroom-link-data");
}
const teamPlan = link.team?.plan || "free";
const returnLink = {
...link,
...linkData,
dataroomId: undefined,
...(teamPlan === "free" && {
customFields: [], // reset custom fields for free plan
enableAgreement: false,
enableWatermark: false,
permissionGroupId: null,
}),
};
return res.status(200).json({ linkType, link: returnLink, brand });
} catch (error) {
console.error("Error fetching link data:", error);
return res.status(500).json({
message: "Internal Server Error",
error: (error as Error).message,
});
}
} else if (req.method === "PUT") {
// PUT /api/links/:id
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const userId = (session.user as CustomUser).id;
const { id } = req.query as { id: string };
const {
targetId,
linkType,
password,
expiresAt,
teamId,
...linkDomainData
} = req.body;
const dataroomLink = linkType === "DATAROOM_LINK";
const documentLink = linkType === "DOCUMENT_LINK";
try {
const existingLink = await prisma.link.findUnique({
where: {
id: id,
teamId: teamId,
team: {
users: {
some: { userId },
},
},
},
});
if (!existingLink) {
return res
.status(404)
.json({ error: "Link not found or unauthorized" });
}
} catch (error) {
return res.status(500).json({
message: "Internal Server Error",
error: (error as Error).message,
});
}
const hashedPassword =
password && password.length > 0
? await generateEncrpytedPassword(password)
: null;
const exat = expiresAt ? new Date(expiresAt) : null;
let { domain, slug, ...linkData } = linkDomainData;
// set domain and slug to null if the domain is dataroom.hanzo.ai
if (domain && domain === "dataroom.hanzo.ai") {
domain = null;
slug = null;
}
let domainObj: DomainObject | null;
if (domain && slug) {
domainObj = await prisma.domain.findUnique({
where: {
slug: domain,
},
});
if (!domainObj) {
return res.status(400).json({ error: "Domain not found." });
}
const currentLink = await prisma.link.findUnique({
where: { id: id },
select: {
id: true,
domainSlug: true,
slug: true,
},
});
// if the slug or domainSlug has changed, check if the new slug is unique
if (currentLink?.slug !== slug || currentLink?.domainSlug !== domain) {
const existingLink = await prisma.link.findUnique({
where: {
domainSlug_slug: {
slug: slug,
domainSlug: domain,
},
},
});
if (existingLink) {
return res.status(400).json({
error: "The link already exists.",
});
}
}
}
if (linkData.enableAgreement && !linkData.agreementId) {
return res.status(400).json({
error: "No agreement selected.",
});
}
if (linkData.enableWatermark) {
if (!linkData.watermarkConfig) {
return res.status(400).json({
error:
"Watermark configuration is required when watermark is enabled.",
});
}
// Validate the watermark config structure
const validation = WatermarkConfigSchema.safeParse(
linkData.watermarkConfig,
);
if (!validation.success) {
return res.status(400).json({
error: "Invalid watermark configuration.",
details: validation.error.issues
.map((issue) => issue.message)
.join(", "),
});
}
}
// Validate visitor group IDs belong to this team
if (linkData.visitorGroupIds?.length > 0) {
const validGroups = await prisma.visitorGroup.findMany({
where: {
id: { in: linkData.visitorGroupIds },
teamId: teamId,
},
select: { id: true },
});
if (validGroups.length !== linkData.visitorGroupIds.length) {
return res.status(400).json({
error: "One or more visitor group IDs do not belong to this team.",
});
}
}
const updatedLink = await prisma.$transaction(async (tx) => {
const link = await tx.link.update({
where: { id, teamId },
data: {
documentId: documentLink ? targetId : null,
dataroomId: dataroomLink ? targetId : null,
password: hashedPassword,
name: linkData.name || null,
emailProtected:
linkData.audienceType === LinkAudienceType.GROUP
? true
: linkData.emailProtected,
emailAuthenticated: linkData.emailAuthenticated,
allowDownload: linkData.allowDownload,
allowList: linkData.allowList,
denyList: linkData.denyList,
expiresAt: exat,
domainId: domainObj?.id || null,
domainSlug: domain || null,
slug: slug || null,
enableIndexFile: linkData.enableIndexFile || false,
enableNotification: linkData.enableNotification,
enableFeedback: linkData.enableFeedback,
enableScreenshotProtection: linkData.enableScreenshotProtection,
enableCustomMetatag: linkData.enableCustomMetatag,
metaTitle: linkData.metaTitle || null,
metaDescription: linkData.metaDescription || null,
metaImage: linkData.metaImage || null,
metaFavicon: linkData.metaFavicon || null,
welcomeMessage: linkData.welcomeMessage || null,
...(linkData.customFields && {
customFields: {
deleteMany: {}, // Delete all existing custom fields
createMany: {
data: linkData.customFields.map(
(field: any, index: number) => ({
type: field.type,
identifier: field.identifier,
label: field.label,
placeholder: field.placeholder,
required: field.required,
disabled: field.disabled,
orderIndex: index,
}),
),
},
},
}),
enableQuestion: linkData.enableQuestion,
...(linkData.enableQuestion && {
feedback: {
upsert: {
create: {
data: {
question: linkData.questionText,
type: linkData.questionType,
},
},
update: {
data: {
question: linkData.questionText,
type: linkData.questionType,
},
},
},
},
}),
enableAgreement: linkData.enableAgreement,
agreementId: linkData.agreementId || null,
showBanner: linkData.showBanner,
enableWatermark: linkData.enableWatermark || false,
watermarkConfig: linkData.watermarkConfig || null,
groupId: linkData.groupId || null,
permissionGroupId: linkData.permissionGroupId || null,
audienceType: linkData.audienceType || LinkAudienceType.GENERAL,
enableConversation: linkData.enableConversation || false,
enableAIAgents: linkData.enableAIAgents || false,
enableUpload: linkData.enableUpload || false,
isFileRequestOnly: linkData.isFileRequestOnly || false,
uploadFolderId: linkData.uploadFolderId || null,
},
include: {
customFields: true,
visitorGroups: {
select: {
visitorGroupId: true,
},
},
views: {
orderBy: {
viewedAt: "desc",
},
take: 1,
},
_count: {
select: { views: true },
},
},
});
// Update visitor groups (replace all)
if (linkData.visitorGroupIds !== undefined) {
// Delete existing visitor group associations
await tx.linkVisitorGroup.deleteMany({
where: { linkId: id },
});
// Create new associations
if (linkData.visitorGroupIds?.length > 0) {
await insert(tx.linkVisitorGroup, linkData.visitorGroupIds.map(
(visitorGroupId: string) => ({
linkId: id,
visitorGroupId,
}),
));
}
}
if (linkData.tags?.length) {
// Remove only tags that are not in the new list
await tx.tagItem.deleteMany({
where: {
linkId: id,
itemType: "LINK_TAG",
tagId: { notIn: linkData.tags },
},
});
// Add new tags while avoiding duplicates
await insert(tx.tagItem, linkData.tags.map((tagId: string) => ({
tagId,
itemType: "LINK_TAG",
linkId: id,
taggedBy: userId,
})));
} else {
// If all tags are removed, delete all tagged items for this link
await tx.tagItem.deleteMany({
where: {
linkId: id,
itemType: "LINK_TAG",
},
});
}
const tags = await tx.tag.findMany({
where: {
items: {
some: { linkId: link.id },
},
},
select: {
id: true,
name: true,
color: true,
description: true,
},
});
// Re-fetch visitor groups to get post-update associations
const freshVisitorGroups = await tx.linkVisitorGroup.findMany({
where: { linkId: id },
select: { visitorGroupId: true },
});
return { ...link, visitorGroups: freshVisitorGroups, tags };
});
if (!updatedLink) {
return res.status(404).json({ error: "Link not found" });
}
await fetch(
`${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&linkId=${id}&hasDomain=${updatedLink.domainId ? "true" : "false"}`,
);
// Decrypt the password for the updated link
if (updatedLink.password !== null) {
updatedLink.password = decryptEncrpytedPassword(updatedLink.password);
}
return res.status(200).json(updatedLink);
} else if (req.method == "DELETE") {
// DELETE /api/links/:id
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const userId = (session.user as CustomUser).id;
const { id } = req.query as { id: string };
try {
const linkToBeDeleted = await prisma.link.findUnique({
where: {
id: id,
},
include: {
document: {
select: {
ownerId: true,
},
},
dataroom: {
select: {
teamId: true,
},
},
team: {
select: {
plan: true,
users: {
where: {
userId: userId,
},
select: {
userId: true,
role: true,
},
},
},
},
},
});
if (!linkToBeDeleted) {
return res.status(404).json({ error: "Link not found" });
}
// Check if team is on free plan
if (linkToBeDeleted.team?.plan === "free") {
return res.status(403).json({
error:
"Link deletion is not available on the free plan. Please upgrade to delete links.",
});
}
// Check authorization based on link type
let isAuthorized = false;
if (linkToBeDeleted.documentId && linkToBeDeleted.document) {
// Document link - check if user owns the document
isAuthorized = linkToBeDeleted.document.ownerId === userId;
} else if (linkToBeDeleted.dataroomId && linkToBeDeleted.team) {
// Dataroom link - check if user is a member of the team
isAuthorized = linkToBeDeleted.team.users.length > 0;
}
if (!isAuthorized) {
return res.status(401).end("Unauthorized to delete this link");
}
// Generate a random suffix for the deleted slug to free up the original slug
const generateDeletedSuffix = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
6,
);
// Soft delete the link by setting deletedAt and isArchived,
// and rename the slug so the original can be reused
await prisma.link.update({
where: {
id: id,
},
data: {
deletedAt: new Date(),
isArchived: true,
...(linkToBeDeleted.slug && {
slug: `${linkToBeDeleted.slug}-DELETED-${generateDeletedSuffix()}`,
}),
},
});
res.status(204).end(); // 204 No Content response for successful deletes
} catch (error) {
return res.status(500).json({
message: "Internal Server Error",
error: (error as Error).message,
});
}
}
// We only allow GET and PUT requests
res.setHeader("Allow", ["GET", "PUT", "DELETE"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}