feat: add envelope editor

This commit is contained in:
David Nguyen
2025-10-12 23:35:54 +11:00
parent bf89bc781b
commit 0da8e7dbc6
307 changed files with 24657 additions and 3681 deletions

View File

@ -35,6 +35,8 @@ import {
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isRequiredField } from '../../utils/advanced-fields-helpers';
import { extractDerivedDocumentMeta } from '../../utils/document';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
@ -107,7 +109,7 @@ export const createDocumentFromDirectTemplate = async ({
},
directLink: true,
envelopeItems: {
select: {
include: {
documentData: true,
},
},
@ -129,10 +131,8 @@ export const createDocumentFromDirectTemplate = async ({
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
const firstEnvelopeItem = directTemplateEnvelope.envelopeItems[0];
// Todo: Envelopes
if (directTemplateEnvelope.envelopeItems.length !== 1 || !firstEnvelopeItem) {
if (directTemplateEnvelope.envelopeItems.length < 1) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid number of envelope items',
});
@ -228,7 +228,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
documentId: template.id,
envelopeId: directTemplateEnvelope.id,
},
field: templateField,
userId: user?.id,
@ -289,14 +289,43 @@ export const createDocumentFromDirectTemplate = async ({
const initialRequestTime = new Date();
// Todo: Envelopes
const documentData = await prisma.documentData.create({
data: {
type: firstEnvelopeItem.documentData.type,
data: firstEnvelopeItem.documentData.data,
initialData: firstEnvelopeItem.documentData.initialData,
},
});
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
// Duplicate the envelope item data.
const envelopeItemsToCreate = await Promise.all(
directTemplateEnvelope.envelopeItems.map(async (item, i) => {
const buffer = await getFileServerSide(item.documentData);
const titleToUse = item.title || directTemplateEnvelope.title;
const duplicatedFile = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
return {
id: newEnvelopeItemId,
title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
documentDataId: newDocumentData.id,
order: item.order !== undefined ? item.order : i + 1,
};
}),
);
const documentMeta = await prisma.documentMeta.create({
data: derivedDocumentMeta,
@ -311,6 +340,7 @@ export const createDocumentFromDirectTemplate = async ({
id: prefixedId('envelope'),
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: 1,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId,
@ -322,10 +352,8 @@ export const createDocumentFromDirectTemplate = async ({
externalId: directTemplateExternalId,
visibility: settings.documentVisibility,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: directTemplateEnvelope.title, // Todo: Envelopes use item title instead
documentDataId: documentData.id,
createMany: {
data: envelopeItemsToCreate,
},
},
authOptions: createDocumentAuthOptions({
@ -374,8 +402,6 @@ export const createDocumentFromDirectTemplate = async ({
},
});
const envelopeItemId = createdEnvelope.envelopeItems[0].id;
let nonDirectRecipientFieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
Object.values(nonDirectTemplateRecipients).forEach((templateRecipient) => {
@ -390,7 +416,7 @@ export const createDocumentFromDirectTemplate = async ({
nonDirectRecipientFieldsToCreate = nonDirectRecipientFieldsToCreate.concat(
templateRecipient.fields.map((field) => ({
envelopeId: createdEnvelope.id,
envelopeItemId: envelopeItemId, // Todo: Envelopes
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
recipientId: recipient.id,
type: field.type,
page: field.page,
@ -432,7 +458,7 @@ export const createDocumentFromDirectTemplate = async ({
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
envelopeId: createdEnvelope.id,
envelopeItemId: envelopeItemId, // Todo: Envelopes
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
@ -463,7 +489,7 @@ export const createDocumentFromDirectTemplate = async ({
const field = await tx.field.create({
data: {
envelopeId: createdEnvelope.id,
envelopeItemId: envelopeItemId, // Todo: Envelopes
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
recipientId: createdDirectRecipient.id,
type: templateField.type,
page: templateField.page,

View File

@ -41,6 +41,8 @@ import {
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -77,7 +79,17 @@ export type CreateDocumentFromTemplateOptions = {
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
customDocumentData?: {
documentDataId: string;
/**
* The envelope item ID which will be updated to use the custom document data.
*
* If undefined, will use the first envelope item. This is done for backwards compatibility reasons.
*/
envelopeItemId?: string;
}[];
/**
* Values that will override the predefined values in the template.
@ -278,7 +290,7 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
customDocumentDataId,
customDocumentData = [],
override,
requestMetadata,
folderId,
@ -331,11 +343,11 @@ export const createDocumentFromTemplate = async ({
}
const legacyTemplateId = mapSecondaryIdToTemplateId(template.secondaryId);
const finalEnvelopeTitle = override?.title || template.title;
// Todo: Envelopes
if (template.envelopeItems.length !== 1) {
if (template.envelopeItems.length < 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Template must have exactly 1 envelope item',
message: 'Template must have at least 1 envelope item',
});
}
@ -376,32 +388,73 @@ export const createDocumentFromTemplate = async ({
};
});
// Todo: Envelopes
let parentDocumentData = template.envelopeItems[0].documentData;
const firstEnvelopeItemId = template.envelopeItems[0].id;
if (customDocumentDataId) {
const customDocumentData = await prisma.documentData.findFirst({
where: {
id: customDocumentDataId,
},
});
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
if (!customDocumentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Custom document data not found',
// Duplicate the envelope item data.
// Todo: Envelopes - Ask if it's okay to just use the documentDataId? Or should it be duplicated?
// Note: This is duplicated in createDocumentFromDirectTemplate
const envelopeItemsToCreate = await Promise.all(
template.envelopeItems.map(async (item, i) => {
let documentDataIdToDuplicate = item.documentDataId;
const foundCustomDocumentData = customDocumentData.find(
(customDocumentDataItem) =>
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
);
if (foundCustomDocumentData) {
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
}
const documentDataToDuplicate = await prisma.documentData.findFirst({
where: {
id: documentDataIdToDuplicate,
},
});
}
parentDocumentData = customDocumentData;
}
if (!documentDataToDuplicate) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
const documentData = await prisma.documentData.create({
data: {
type: parentDocumentData.type,
data: parentDocumentData.data,
initialData: parentDocumentData.initialData,
},
});
const buffer = await getFileServerSide(documentDataToDuplicate);
// Todo: Envelopes [PRE-MAIN] - Should we normalize? Should be part of the upload.
// const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const titleToUse = item.title || finalEnvelopeTitle;
const duplicatedFile = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
return {
id: newEnvelopeItemId,
title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
documentDataId: newDocumentData.id,
order: item.order !== undefined ? item.order : i + 1,
};
}),
);
const incrementedDocumentId = await incrementDocumentId();
@ -433,6 +486,7 @@ export const createDocumentFromTemplate = async ({
id: prefixedId('envelope'),
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: template.internalVersion,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
@ -440,12 +494,10 @@ export const createDocumentFromTemplate = async ({
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
title: finalEnvelopeTitle,
envelopeItems: {
create: {
id: prefixedId('envelope_item'),
title: override?.title || template.title,
documentDataId: documentData.id,
createMany: {
data: envelopeItemsToCreate,
},
},
authOptions: createDocumentAuthOptions({
@ -495,8 +547,6 @@ export const createDocumentFromTemplate = async ({
},
});
const envelopeItemId = envelope.envelopeItems[0].id;
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId'>[] = [];
// Get all template field IDs first so we can validate later
@ -552,8 +602,8 @@ export const createDocumentFromTemplate = async ({
const prefillField = prefillFields?.find((value) => value.id === field.id);
const payload = {
envelopeItemId,
envelopeId: envelope.id, // Todo: Envelopes
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
envelopeId: envelope.id,
recipientId: recipient.id,
type: field.type,
page: field.page,

View File

@ -107,5 +107,6 @@ export const createTemplateDirectLink = async ({
enabled: createdDirectLink.enabled,
directTemplateRecipientId: createdDirectLink.directTemplateRecipientId,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
};
};

View File

@ -2,20 +2,18 @@ import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type DeleteTemplateOptions = {
id: number;
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id,
},
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,

View File

@ -1,139 +0,0 @@
import { DocumentSource, EnvelopeType } from '@prisma/client';
import { omit } from 'remeda';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementTemplateId } from '../envelope/increment-id';
export type DuplicateTemplateOptions = {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
};
export const duplicateTemplate = async ({ id, userId, teamId }: DuplicateTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
select: {
email: true,
name: true,
role: true,
signingOrder: true,
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const { formattedTemplateId } = await incrementTemplateId();
const createdDocumentMeta = await prisma.documentMeta.create({
data: {
...omit(envelope.documentMeta, ['id']),
emailSettings: envelope.documentMeta.emailSettings || undefined,
},
});
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId: formattedTemplateId,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
title: envelope.title + ' (copy)',
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
source: DocumentSource.DOCUMENT, // Todo: Migration what to use here.
},
include: {
recipients: true,
},
});
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
// Duplicate the envelope items.
await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
const duplicatedDocumentData = await prisma.documentData.create({
data: {
type: envelopeItem.documentData.type,
data: envelopeItem.documentData.initialData,
initialData: envelopeItem.documentData.initialData,
},
});
const duplicatedEnvelopeItem = await prisma.envelopeItem.create({
data: {
id: prefixedId('envelope_item'),
title: envelopeItem.title,
envelopeId: duplicatedEnvelope.id,
documentDataId: duplicatedDocumentData.id,
},
});
oldEnvelopeItemToNewEnvelopeItemIdMap[envelopeItem.id] = duplicatedEnvelopeItem.id;
}),
);
for (const recipient of envelope.recipients) {
await prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
},
},
});
}
return duplicatedEnvelope;
};

View File

@ -58,6 +58,7 @@ export const getTemplateByDirectLinkToken = async ({
// Backwards compatibility mapping.
return {
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
@ -70,7 +71,10 @@ export const getTemplateByDirectLinkToken = async ({
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
templateDocumentData: firstDocumentData,
templateDocumentData: {
...firstDocumentData,
envelopeItemId: envelope.envelopeItems[0].id,
},
directLink,
templateMeta: envelope.documentMeta,
recipients: recipientsWithMappedFields,

View File

@ -3,21 +3,19 @@ import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetTemplateByIdOptions = {
id: number;
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id,
},
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
@ -30,6 +28,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
documentMeta: true,
envelopeItems: {
select: {
id: true,
documentData: true,
},
},
@ -52,7 +51,6 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
});
}
// Todo: Envelopes
const firstTemplateDocumentData = envelope.envelopeItems[0].documentData;
if (!firstTemplateDocumentData) {
@ -68,8 +66,12 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
return {
...rest,
envelopeId: envelope.id,
type: envelope.templateType,
templateDocumentData: firstTemplateDocumentData,
templateDocumentData: {
...firstTemplateDocumentData,
envelopeItemId: envelope.envelopeItems[0].id,
},
templateMeta: envelope.documentMeta,
fields: envelope.fields.map((field) => ({
...field,

View File

@ -68,5 +68,6 @@ export const toggleTemplateDirectLink = async ({
enabled: updatedDirectLink.enabled,
directTemplateRecipientId: updatedDirectLink.directTemplateRecipientId,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
};
};

View File

@ -1,185 +0,0 @@
import type { Prisma, TemplateType } from '@prisma/client';
import {
type DocumentMeta,
type DocumentVisibility,
EnvelopeType,
FolderType,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type UpdateTemplateOptions = {
userId: number;
teamId: number;
templateId: number;
data?: {
title?: string;
folderId?: string | null;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string;
publicDescription?: string;
type?: TemplateType;
useLegacyFieldInsertion?: boolean;
};
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
};
export const updateTemplate = async ({
userId,
teamId,
templateId,
meta = {},
data = {},
}: UpdateTemplateOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
documentMeta: true,
team: {
select: {
organisationId: true,
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
if (Object.values(data).length === 0 && Object.keys(meta).length === 0) {
return envelope;
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth.length > 0 && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const emailId = meta.emailId;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: envelope.team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
let folderUpdateQuery: Prisma.FolderUpdateOneWithoutEnvelopesNestedInput | undefined = undefined;
// Validate folder ID.
if (data.folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: data.folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type: FolderType.TEMPLATE,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderUpdateQuery = {
connect: {
id: data.folderId,
},
};
}
// Move template to root folder if folderId is null.
if (data.folderId === null) {
folderUpdateQuery = {
disconnect: true,
};
}
return await prisma.envelope.update({
where: {
id: envelope.id,
type: EnvelopeType.TEMPLATE,
},
data: {
templateType: data?.type,
title: data?.title,
externalId: data?.externalId,
visibility: data?.visibility,
publicDescription: data?.publicDescription,
publicTitle: data?.publicTitle,
useLegacyFieldInsertion: data?.useLegacyFieldInsertion,
folder: folderUpdateQuery,
authOptions,
documentMeta: {
update: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
},
},
});
};