feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -1,6 +1,7 @@
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSource,
EnvelopeType,
type Field,
FolderType,
type Recipient,
@ -37,9 +38,11 @@ import {
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
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 {
@ -47,7 +50,11 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -60,7 +67,7 @@ type FinalRecipient = Pick<
};
export type CreateDocumentFromTemplateOptions = {
templateId: number;
id: EnvelopeIdOptions;
externalId?: string | null;
userId: number;
teamId: number;
@ -72,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.
@ -268,30 +285,38 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
};
export const createDocumentFromTemplate = async ({
templateId,
id,
externalId,
userId,
teamId,
recipients,
customDocumentDataId,
customDocumentData = [],
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
},
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
templateDocumentData: true,
templateMeta: true,
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
@ -317,6 +342,15 @@ export const createDocumentFromTemplate = async ({
}
}
const legacyTemplateId = mapSecondaryIdToTemplateId(template.secondaryId);
const finalEnvelopeTitle = override?.title || template.title;
if (template.envelopeItems.length < 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Template must have at least 1 envelope item',
});
}
const settings = await getTeamSettings({
userId,
teamId,
@ -354,74 +388,121 @@ export const createDocumentFromTemplate = async ({
};
});
let parentDocumentData = template.templateDocumentData;
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.
// 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);
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();
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.documentMeta?.subject,
message: override?.message || template.documentMeta?.message,
timezone: override?.timezone || template.documentMeta?.timezone,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled:
override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
}),
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: template.internalVersion,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
templateId: template.id,
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {
data: envelopeItemsToCreate,
},
},
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.visibility || settings.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.templateMeta?.emailSettings,
signingOrder: override?.signingOrder || template.templateMeta?.signingOrder,
language:
override?.language || template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled:
override?.uploadSignatureEnabled ?? template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
documentMetaId: documentMeta.id,
recipients: {
createMany: {
data: finalRecipients.map((recipient) => {
@ -454,11 +535,15 @@ export const createDocumentFromTemplate = async ({
id: 'asc',
},
},
documentData: true,
envelopeItems: {
select: {
id: true,
},
},
},
});
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId'>[] = [];
// Get all template field IDs first so we can validate later
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
@ -502,7 +587,7 @@ export const createDocumentFromTemplate = async ({
}
Object.values(finalRecipients).forEach(({ token, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.token === token);
const recipient = envelope.recipients.find((recipient) => recipient.token === token);
if (!recipient) {
throw new Error('Recipient not found.');
@ -513,7 +598,8 @@ export const createDocumentFromTemplate = async ({
const prefillField = prefillFields?.find((value) => value.id === field.id);
const payload = {
documentId: document.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
envelopeId: envelope.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
@ -544,7 +630,7 @@ export const createDocumentFromTemplate = async ({
}
payload.customText = DateTime.fromJSDate(date).toFormat(
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
template.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
);
payload.inserted = true;
@ -569,21 +655,21 @@ export const createDocumentFromTemplate = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
title: document.title,
title: envelope.title,
source: {
type: DocumentSource.TEMPLATE,
templateId: template.id,
templateId: legacyTemplateId,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: document.id,
id: envelope.id,
},
include: {
documentMeta: true,
@ -591,17 +677,17 @@ export const createDocumentFromTemplate = async ({
},
});
if (!createdDocument) {
if (!createdEnvelope) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return document;
return envelope;
});
};