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,7 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
@ -11,6 +11,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
@ -24,10 +25,14 @@ export const run = async ({
}) => {
const { documentId, cancellationReason } = payload;
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
const envelope = await prisma.envelope.findFirstOrThrow({
where: unsafeBuildEnvelopeIdQuery(
{
type: 'documentId',
id: documentId,
},
EnvelopeType.DOCUMENT,
),
include: {
user: {
select: {
@ -52,12 +57,12 @@ export const run = async ({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
teamId: envelope.teamId,
},
meta: document.documentMeta,
meta: envelope.documentMeta,
});
const { documentMeta, user: documentOwner } = document;
const { documentMeta, user: documentOwner } = envelope;
// Check if document cancellation emails are enabled
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
@ -69,7 +74,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
const recipientsToNotify = envelope.recipients.filter(
(recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED,
@ -79,7 +84,7 @@ export const run = async ({
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
@ -102,7 +107,7 @@ export const run = async ({
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" Cancelled`),
subject: i18n._(msg`Document "${envelope.title}" Cancelled`),
html,
text,
});

View File

@ -1,6 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
@ -10,6 +11,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
@ -23,9 +25,15 @@ export const run = async ({
}) => {
const { documentId, recipientId } = payload;
const document = await prisma.document.findFirst({
const envelope = await prisma.envelope.findFirst({
where: {
id: documentId,
...unsafeBuildEnvelopeIdQuery(
{
type: 'documentId',
id: documentId,
},
EnvelopeType.DOCUMENT,
),
recipients: {
some: {
id: recipientId,
@ -49,25 +57,25 @@ export const run = async ({
},
});
if (!document) {
if (!envelope) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
if (envelope.recipients.length === 0) {
throw new Error('Document has no recipients');
}
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
envelope.documentMeta,
).recipientSigned;
if (!isRecipientSignedEmailEnabled) {
return;
}
const [recipient] = document.recipients;
const [recipient] = envelope.recipients;
const { email: recipientEmail, name: recipientName } = recipient;
const { user: owner } = document;
const { user: owner } = envelope;
const recipientReference = recipientName || recipientEmail;
@ -80,9 +88,9 @@ export const run = async ({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
teamId: envelope.teamId,
},
meta: document.documentMeta,
meta: envelope.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -90,7 +98,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
documentName: envelope.title,
recipientName,
recipientEmail,
assetBaseUrl,
@ -112,7 +120,7 @@ export const run = async ({
address: owner.email,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
html,
text,
});

View File

@ -1,7 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
@ -13,6 +13,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
@ -27,11 +28,15 @@ export const run = async ({
}) => {
const { documentId, recipientId } = payload;
const [document, recipient] = await Promise.all([
prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
const [envelope, recipient] = await Promise.all([
prisma.envelope.findFirstOrThrow({
where: unsafeBuildEnvelopeIdQuery(
{
type: 'documentId',
id: documentId,
},
EnvelopeType.DOCUMENT,
),
include: {
user: {
select: {
@ -58,10 +63,10 @@ export const run = async ({
}),
]);
const { user: documentOwner } = document;
const { user: documentOwner } = envelope;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
envelope.documentMeta,
).recipientSigningRequest;
if (!isEmailEnabled) {
@ -72,9 +77,9 @@ export const run = async ({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
teamId: envelope.teamId,
},
meta: document.documentMeta,
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
@ -83,8 +88,8 @@ export const run = async ({
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentOwnerName: document.user.name || document.user.email,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
@ -105,7 +110,7 @@ export const run = async ({
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
html,
text,
});
@ -115,9 +120,9 @@ export const run = async ({
await io.runTask('send-owner-notification-email', async () => {
const ownerTemplate = createElement(DocumentRejectedEmail, {
recipientName: recipient.name,
documentName: document.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
document.id
documentName: envelope.title,
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${
envelope.id
}`,
rejectionReason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
@ -138,7 +143,7 @@ export const run = async ({
address: documentOwner.email,
},
from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here.
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
subject: i18n._(msg`Document "${envelope.title}" - Rejected by ${recipient.name}`),
html,
text,
});

View File

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import {
DocumentSource,
DocumentStatus,
EnvelopeType,
OrganisationType,
RecipientRole,
SendStatus,
@ -23,6 +24,7 @@ import { getEmailContext } from '../../../server-only/email/get-email-context';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
@ -37,7 +39,7 @@ export const run = async ({
}) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
const [user, document, recipient] = await Promise.all([
const [user, envelope, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -48,9 +50,15 @@ export const run = async ({
name: true,
},
}),
prisma.document.findFirstOrThrow({
prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
...unsafeBuildEnvelopeIdQuery(
{
type: 'documentId',
id: documentId,
},
EnvelopeType.DOCUMENT,
),
status: DocumentStatus.PENDING,
},
include: {
@ -70,14 +78,14 @@ export const run = async ({
}),
]);
const { documentMeta, team } = document;
const { documentMeta, team } = envelope;
if (recipient.role === RecipientRole.CC) {
return;
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
envelope.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
@ -89,13 +97,13 @@ export const run = async ({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
teamId: envelope.teamId,
},
meta: document.documentMeta,
meta: envelope.documentMeta,
});
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const customEmail = envelope?.documentMeta;
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -113,7 +121,7 @@ export const run = async ({
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
@ -136,8 +144,8 @@ export const run = async ({
emailMessage = i18n._(
settings.includeSenderDetails
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
}
@ -145,14 +153,14 @@ export const run = async ({
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
'document.name': envelope.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
@ -210,7 +218,7 @@ export const run = async ({
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
envelopeId: envelope.id,
user,
requestMetadata,
data: {

View File

@ -37,7 +37,10 @@ export const run = async ({
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
const template = await getTemplateById({
id: templateId,
id: {
type: 'templateId',
id: templateId,
},
userId,
teamId,
});
@ -99,9 +102,12 @@ export const run = async ({
}
}
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
templateId: template.id,
id: {
type: 'templateId',
id: template.id,
},
userId,
teamId,
recipients: recipients.map((recipient, index) => {
@ -124,7 +130,10 @@ export const run = async ({
if (sendImmediately) {
await io.runTask(`send-document-${rowIndex}`, async () => {
await sendDocument({
documentId: document.id,
id: {
type: 'envelopeId',
id: envelope.id,
},
userId,
teamId,
requestMetadata: {

View File

@ -1,7 +1,14 @@
import { DocumentStatus, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { PDFDocument } from '@cantoo/pdf-lib';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
@ -14,7 +21,8 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
@ -22,7 +30,7 @@ import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-we
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
@ -30,6 +38,7 @@ import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { mapDocumentIdToSecondaryId, mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
@ -42,24 +51,39 @@ export const run = async ({
}) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const document = await prisma.document.findFirstOrThrow({
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: documentId,
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
documentMeta: true,
recipients: true,
envelopeItems: {
include: {
documentData: true,
field: {
include: {
signature: true,
},
},
},
},
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
@ -71,28 +95,28 @@ export const run = async ({
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return document.status;
return envelope.status;
});
// This is the same case as above.
// eslint-disable-next-line @typescript-eslint/require-await
const documentDataId = await io.runTask('get-document-data-id', async () => {
return document.documentDataId;
});
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
let envelopeItems = await io.runTask(
'get-document-data-id',
// eslint-disable-next-line @typescript-eslint/require-await
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
});
);
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
@ -111,7 +135,7 @@ export const run = async ({
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
envelopeId: envelope.id,
},
include: {
signature: true,
@ -120,19 +144,25 @@ export const run = async ({
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`);
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
documentData.data = documentData.initialData;
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!document.qrToken) {
await prisma.document.update({
if (!envelope.qrToken) {
await prisma.envelope.update({
where: {
id: document.id,
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
@ -140,97 +170,38 @@ export const run = async ({
});
}
const pdfData = await getFileServerSide(documentData);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const certificateData = settings.includeSigningCertificate
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(pdfDoc, field)
: await insertFieldInPDF(pdfDoc, field);
}
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
await flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(document.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const documentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
return documentData.id;
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask('decorate-and-sign-pdf', async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
}),
),
);
const postHog = PostHogServerClient();
if (postHog) {
@ -238,7 +209,7 @@ export const run = async ({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: document.id,
documentId: envelope.id,
isRejected,
},
});
@ -246,15 +217,26 @@ export const run = async ({
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
const newData = await tx.documentData.findFirstOrThrow({
where: {
id: newDataId,
},
});
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({
where: {
id: newDocumentDataId,
},
});
await tx.document.update({
await tx.documentData.update({
where: {
id: oldDocumentDataId,
},
data: {
data: newData.data,
},
});
}
await tx.envelope.update({
where: {
id: document.id,
id: envelope.id,
},
data: {
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
@ -262,19 +244,10 @@ export const run = async ({
},
});
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData.data,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
envelopeId: envelope.id,
requestMetadata,
user: null,
data: {
@ -289,21 +262,23 @@ export const run = async ({
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(document.status)) {
if (isResealing && !isDocumentCompleted(envelope.status)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id },
requestMetadata,
});
}
});
const updatedDocument = await prisma.document.findFirstOrThrow({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
id: document.id,
id: envelope.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
},
@ -313,8 +288,148 @@ export const run = async ({
event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
userId: updatedEnvelope.userId,
teamId: updatedEnvelope.teamId ?? undefined,
});
};
type DecorateAndSignPdfOptions = {
envelope: Pick<Envelope, 'id' | 'title' | 'useLegacyFieldInsertion' | 'internalVersion'>;
envelopeItem: EnvelopeItem & { documentData: DocumentData };
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateData: Buffer | null;
auditLogData: Buffer | null;
};
/**
* Fetch, normalize, flatten and insert fields into a PDF document.
*/
const decorateAndSignPdf = async ({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.internalVersion === 2) {
await insertFieldInPDFV2(pdfDoc, field);
} else if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
}
}
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
await flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(envelopeItem.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
});
return {
oldDocumentDataId: envelopeItem.documentData.id,
newDocumentDataId: newDocumentData.id,
};
};
export const getCertificateAndAuditLogData = async ({
legacyDocumentId,
documentMeta,
settings,
}: {
legacyDocumentId: number;
documentMeta: DocumentMeta;
settings: { includeSigningCertificate: boolean; includeAuditLog: boolean };
}) => {
const getCertificateDataPromise = settings.includeSigningCertificate
? getCertificatePdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const getAuditLogDataPromise = settings.includeAuditLog
? getAuditLogsPdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const [certificateData, auditLogData] = await Promise.all([
getCertificateDataPromise,
getAuditLogDataPromise,
]);
return {
certificateData,
auditLogData,
};
};