fix: move sealing to a background job (#1287)

When signing a document the final signer is often
greeted with a super long completing spinner since
we are synchronously signing the document and sending
emails to all recipients. This is frustrating and
has caused issues for customers and self-hosters.

Moving sealing to a background job resolves this
and improves the overall snappiness of the app while
also supporting retrying the sealing if it were to
fail in the future.

This has the implication of a document no longer
immediately being in a "completed" state once all
signers have signed. To assist with this we now
refetch the page every 5 seconds upon signing
completion until the document status as shifted to
completed.
This commit is contained in:
Lucas Smith
2024-08-14 13:12:32 +10:00
committed by GitHub
parent 20ec2dde3d
commit ab8701526c
9 changed files with 326 additions and 23 deletions

View File

@ -449,14 +449,18 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.waitForURL('https://documenso.com');
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await expect(async () => {
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
const user = await seedUser();
const customDate = DateTime.utc().toFormat('yyyy-MM-dd hh:mm a');
const now = DateTime.utc();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
@ -488,9 +492,14 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
},
});
expect(field?.customText).toBe(customDate);
const insertedDate = DateTime.fromFormat(field?.customText ?? '', 'yyyy-MM-dd hh:mm a');
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
expect(Math.abs(insertedDate.diff(now).minutes)).toBeLessThanOrEqual(1);
await expect(async () => {
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
}).toPass();
});

View File

@ -4,6 +4,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@ -15,6 +16,7 @@ export const jobsClient = new JobClient([
SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;

View File

@ -0,0 +1,253 @@
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-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 { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { getFile } from '../../../universal/upload/get-file';
import { putPdfFile } from '../../../universal/upload/put-file';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { type JobDefinition } from '../../client/_internal/job';
const SEAL_DOCUMENT_JOB_DEFINITION_ID = 'internal.seal-document';
const SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA = z.object({
documentId: z.number(),
sendEmail: z.boolean().optional(),
isResealing: z.boolean().optional(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
version: '1.0.0',
trigger: {
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
Recipient: true,
},
});
// Seems silly but we need to do this in case the job is re-ran
// 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;
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
role: {
not: RecipientRole.CC,
},
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
},
include: {
Signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned 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;
}
const pdfData = await io.runTask('get-document-data', async () => {
const data = await getFile(documentData);
return Buffer.from(data).toString('base64');
});
const certificateData = await io.runTask('get-certificate-data', async () => {
const data = await getCertificatePdf({ documentId }).catch(() => null);
if (!data) {
return null;
}
return Buffer.from(data).toString('base64');
});
const pdfBuffer = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(Buffer.from(pdfData, 'base64'));
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc);
flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
if (certificateData) {
const certificateDoc = await PDFDocument.load(Buffer.from(certificateData, 'base64'));
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
);
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(pdfDoc, field);
}
// Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields
flattenForm(pdfDoc);
const pdfBytes = await pdfDoc.save();
const buffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
return buffer.toString('base64');
});
const newData = await io.runTask('store-signed-document', async () => {
const { name, ext } = path.parse(document.title);
const { data } = await putPdfFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBuffer, 'base64')),
});
return data;
});
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: document.id,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
});
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing;
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
}
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
documentData: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
},
} as const satisfies JobDefinition<
typeof SEAL_DOCUMENT_JOB_DEFINITION_ID,
z.infer<typeof SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA>
>;

View File

@ -1,5 +1,3 @@
'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@ -7,9 +5,9 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
@ -45,8 +43,6 @@ export const completeDocumentWithToken = async ({
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
const document = await getDocument({ token, documentId });
if (document.status !== DocumentStatus.PENDING) {
@ -149,7 +145,13 @@ export const completeDocumentWithToken = async ({
});
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: document.id,
requestMetadata,
},
});
}
const updatedDocument = await getDocument({ token, documentId });

View File

@ -1,5 +1,3 @@
'use server';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
@ -36,8 +34,6 @@ export const sealDocument = async ({
isResealing = false,
requestMetadata,
}: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,

View File

@ -1,4 +1,3 @@
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -7,7 +6,7 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobsClient } from '../../jobs/client';
import { jobs } from '../../jobs/client';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -141,7 +140,7 @@ export const sendDocument = async ({
return;
}
await jobsClient.triggerJob({
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
@ -160,7 +159,13 @@ export const sendDocument = async ({
);
if (allRecipientsHaveNoActionToTake) {
await sealDocument({ documentId, requestMetadata });
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
},
});
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({

View File

@ -1,6 +1,5 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { chromium } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { encryptSecondaryData } from '../crypto/encrypt';
@ -10,6 +9,8 @@ export type GetCertificatePdfOptions = {
};
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),