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

332 lines
10 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from "next";
import { LinkAudienceType, Tag } from "@prisma/client";
import { after } from "@/lib/after";
import { getServerSession } from "next-auth/next";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
import {
decryptEncrpytedPassword,
generateEncrpytedPassword,
} from "@/lib/utils";
import { sendLinkCreatedWebhook } from "@/lib/webhook/triggers/link-created";
import { authOptions } from "../auth/[...nextauth]";
import { insert } from "@/lib/insert";
export const config = {
// so background work can outlive the response
supportsResponseStreaming: true,
};
export interface DomainObject {
id: string;
slug: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// POST /api/links
if (req.method === "POST") {
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const {
targetId,
linkType,
password,
expiresAt,
teamId,
enableIndexFile,
...linkDomainData
} = req.body;
const userId = (session.user as CustomUser).id;
const dataroomLink = linkType === "DATAROOM_LINK";
const documentLink = linkType === "DOCUMENT_LINK";
try {
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
select: { teamId: true },
});
if (!teamAccess) {
return res.status(401).json({ error: "Unauthorized." });
}
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 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.audienceType === LinkAudienceType.GROUP &&
!linkData.groupId
) {
return res.status(400).json({
error: "No group 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.",
});
}
}
// Fetch the link and its related document from the database
const updatedLink = await prisma.$transaction(async (tx) => {
const link = await tx.link.create({
data: {
documentId: documentLink ? targetId : null,
dataroomId: dataroomLink ? targetId : null,
linkType,
teamId,
ownerId: userId,
password: hashedPassword,
name: linkData.name || null,
emailProtected:
linkData.audienceType === LinkAudienceType.GROUP
? true
: linkData.emailProtected,
emailAuthenticated: linkData.emailAuthenticated,
expiresAt: exat,
allowDownload: linkData.allowDownload,
domainId: domainObj?.id || null,
domainSlug: domain || null,
slug: slug || null,
enableIndexFile: enableIndexFile,
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,
allowList: linkData.allowList,
denyList: linkData.denyList,
audienceType: linkData.audienceType,
groupId:
linkData.audienceType === LinkAudienceType.GROUP
? linkData.groupId
: null,
...(linkData.enableQuestion && {
enableQuestion: linkData.enableQuestion,
feedback: {
create: {
data: {
question: linkData.questionText,
type: linkData.questionType,
},
},
},
}),
...(linkData.enableAgreement && {
enableAgreement: linkData.enableAgreement,
agreementId: linkData.agreementId,
}),
...(linkData.enableWatermark && {
enableWatermark: linkData.enableWatermark,
watermarkConfig: linkData.watermarkConfig,
}),
...(linkData.enableUpload && {
enableUpload: linkData.enableUpload,
isFileRequestOnly: linkData.isFileRequestOnly,
uploadFolderId: linkData.uploadFolderId,
}),
enableAIAgents: linkData.enableAIAgents || false,
enableConversation: linkData.enableConversation || false,
showBanner: linkData.showBanner,
...(linkData.customFields && {
customFields: {
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,
}),
),
},
},
}),
// Connect visitor groups for allow-list
...(linkData.visitorGroupIds?.length > 0 && {
visitorGroups: {
createMany: {
data: linkData.visitorGroupIds.map(
(visitorGroupId: string) => ({
visitorGroupId,
}),
),
},
},
}),
},
include: {
customFields: true,
visitorGroups: {
select: {
visitorGroupId: true,
},
},
},
});
let tags: Partial<Tag>[] = [];
if (linkData.tags?.length) {
// create tag items
await insert(tx.tagItem, linkData.tags.map((tagId: string) => ({
tagId,
itemType: "LINK_TAG",
linkId: link.id,
taggedBy: userId,
})));
// return tags
tags = await tx.tag.findMany({
where: { id: { in: linkData.tags } },
select: { id: true, name: true, color: true, description: true },
});
}
return { ...link, tags };
});
const linkWithView = {
...updatedLink,
_count: { views: 0 },
views: [],
};
if (!linkWithView) {
return res.status(404).json({ error: "Link not found" });
}
after(
sendLinkCreatedWebhook({
teamId,
data: {
link_id: linkWithView.id,
document_id: linkWithView.documentId,
dataroom_id: linkWithView.dataroomId,
},
}),
);
// Revalidate the view page to pre-generate it
await fetch(
`${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&linkId=${linkWithView.id}&hasDomain=${linkWithView.domainId ? "true" : "false"}`,
);
// Decrypt the password for the new link
if (linkWithView.password !== null) {
linkWithView.password = decryptEncrpytedPassword(linkWithView.password);
}
return res.status(200).json(linkWithView);
} catch (error) {
errorhandler(error, res);
}
}
return res.status(405).json({ error: "Method not allowed" });
}