mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
844af17ec2
Each direct mailer.sendMail() call is replaced by a dedicated background job so that email delivery failures can be retried independently. New jobs: send-pending-email, send-completed-email, send-forgot-password-email, send-document-super-delete-email, send-recipient-removed-email, send-document-deleted-emails, send-2fa-token-email, send-resend-document-email, send-direct-template-created-email. Existing handlers (send-document-cancelled-emails, send-organisation-member-*, send-recipient-signed-email) have io.runTask wrappers removed since they interfere with the job scheduler. Job triggers are dispatched after transactions commit to avoid race conditions with uncommitted data.
510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
import { PDFDocument } from '@cantoo/pdf-lib';
|
|
import { PDF } from '@libpdf/core';
|
|
import type { DocumentData, 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 { groupBy } from 'remeda';
|
|
|
|
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
|
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
|
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
|
import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-size';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { signPdf } from '@documenso/signing';
|
|
|
|
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
|
|
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
|
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
|
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-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 { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
|
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
|
import {
|
|
DOCUMENT_AUDIT_LOG_TYPE,
|
|
type TDocumentAuditLog,
|
|
} from '../../../types/document-audit-logs';
|
|
import {
|
|
ZWebhookDocumentSchema,
|
|
mapEnvelopeToWebhookDocumentPayload,
|
|
} from '../../../types/webhook-payload';
|
|
import { prefixedId } from '../../../universal/id';
|
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
|
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 } from '../../../utils/envelope';
|
|
import type { JobRunIO } from '../../client/_internal/job';
|
|
import type { TSealDocumentJobDefinition } from './seal-document';
|
|
|
|
export const run = async ({
|
|
payload,
|
|
io,
|
|
}: {
|
|
payload: TSealDocumentJobDefinition;
|
|
io: JobRunIO;
|
|
}) => {
|
|
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
|
|
|
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
type: EnvelopeType.DOCUMENT,
|
|
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
documentMeta: true,
|
|
recipients: true,
|
|
fields: {
|
|
include: {
|
|
signature: 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: envelope.userId,
|
|
teamId: envelope.teamId,
|
|
});
|
|
|
|
// Ensure all CC recipients are marked as signed
|
|
await prisma.recipient.updateMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
role: RecipientRole.CC,
|
|
},
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
},
|
|
});
|
|
|
|
const isComplete =
|
|
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
|
envelope.recipients.every(
|
|
(recipient) =>
|
|
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
|
|
);
|
|
|
|
if (!isComplete) {
|
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
message: 'Document is not complete',
|
|
});
|
|
}
|
|
|
|
let { envelopeItems } = envelope;
|
|
|
|
const fields = envelope.fields;
|
|
|
|
if (envelopeItems.length < 1) {
|
|
throw new Error(`Document ${envelope.id} has no envelope items`);
|
|
}
|
|
|
|
const recipientsWithoutCCers = envelope.recipients.filter(
|
|
(recipient) => recipient.role !== RecipientRole.CC,
|
|
);
|
|
|
|
// Determine if the document has been rejected by checking if any recipient has rejected it
|
|
const rejectedRecipient = recipientsWithoutCCers.find(
|
|
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
|
);
|
|
|
|
const isRejected = Boolean(rejectedRecipient);
|
|
|
|
// Get the rejection reason from the rejected recipient
|
|
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
|
|
|
// Skip the field check if the document is rejected
|
|
if (!isRejected && fieldsContainUnsignedRequiredField(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.
|
|
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
|
...envelopeItem,
|
|
documentData: {
|
|
...envelopeItem.documentData,
|
|
data: envelopeItem.documentData.initialData,
|
|
},
|
|
}));
|
|
}
|
|
|
|
if (!envelope.qrToken) {
|
|
await prisma.envelope.update({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
qrToken: prefixedId('qr'),
|
|
},
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const envelopeCompletedAuditLog = createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
|
envelopeId: envelope.id,
|
|
requestMetadata,
|
|
user: null,
|
|
data: {
|
|
transactionId: nanoid(),
|
|
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
|
},
|
|
});
|
|
|
|
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
|
|
|
// Pre-fetch all PDF data so we can read dimensions and pass it
|
|
// to decorateAndSignPdf without fetching again.
|
|
const prefetchedItems = await Promise.all(
|
|
envelopeItems.map(async (envelopeItem) => {
|
|
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
|
|
|
return { envelopeItem, pdfData };
|
|
}),
|
|
);
|
|
|
|
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
|
|
|
const needsCertificate = settings.includeSigningCertificate;
|
|
const needsAuditLog = settings.includeAuditLog;
|
|
|
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
|
|
|
for (const { envelopeItem, pdfData } of prefetchedItems) {
|
|
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}`);
|
|
}
|
|
|
|
let certificateDoc: PDF | null = null;
|
|
let auditLogDoc: PDF | null = null;
|
|
|
|
if (needsCertificate || needsAuditLog) {
|
|
const pdfDoc = await PDF.load(pdfData);
|
|
|
|
const { width: pageWidth, height: pageHeight } = getLastPageDimensions(pdfDoc);
|
|
|
|
const additionalAuditLogs = [
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
{
|
|
...envelopeCompletedAuditLog,
|
|
id: '',
|
|
createdAt: new Date(),
|
|
} as TDocumentAuditLog,
|
|
];
|
|
|
|
const certificatePayload = {
|
|
envelope: {
|
|
...envelope,
|
|
status: finalEnvelopeStatus,
|
|
},
|
|
recipients: envelope.recipients,
|
|
fields,
|
|
language: envelope.documentMeta.language,
|
|
envelopeOwner: {
|
|
email: envelope.user.email,
|
|
name: envelope.user.name || '',
|
|
},
|
|
envelopeItems: envelopeItems.map((item) => item.title),
|
|
pageWidth,
|
|
pageHeight,
|
|
additionalAuditLogs,
|
|
};
|
|
|
|
const makeCertificatePdf = async () =>
|
|
usePlaywrightPdf
|
|
? getCertificatePdf({
|
|
documentId,
|
|
language: envelope.documentMeta.language,
|
|
}).then(async (buffer) => PDF.load(buffer))
|
|
: generateCertificatePdf(certificatePayload);
|
|
|
|
const makeAuditLogPdf = async () =>
|
|
usePlaywrightPdf
|
|
? getAuditLogsPdf({
|
|
documentId,
|
|
language: envelope.documentMeta.language,
|
|
}).then(async (buffer) => PDF.load(buffer))
|
|
: generateAuditLogPdf(certificatePayload);
|
|
|
|
[certificateDoc, auditLogDoc] = await Promise.all([
|
|
needsCertificate ? makeCertificatePdf() : null,
|
|
needsAuditLog ? makeAuditLogPdf() : null,
|
|
]);
|
|
}
|
|
|
|
const result = await decorateAndSignPdf({
|
|
envelope,
|
|
envelopeItem,
|
|
envelopeItemFields,
|
|
isRejected,
|
|
rejectionReason,
|
|
pdfData,
|
|
certificateDoc,
|
|
auditLogDoc,
|
|
});
|
|
|
|
newDocumentData.push(result);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
|
const newData = await tx.documentData.findFirstOrThrow({
|
|
where: {
|
|
id: newDocumentDataId,
|
|
},
|
|
});
|
|
|
|
await tx.documentData.update({
|
|
where: {
|
|
id: oldDocumentDataId,
|
|
},
|
|
data: {
|
|
data: newData.data,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.envelope.update({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
status: finalEnvelopeStatus,
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: envelopeCompletedAuditLog,
|
|
});
|
|
});
|
|
|
|
return {
|
|
envelopeId: envelope.id,
|
|
envelopeStatus: envelope.status,
|
|
isRejected,
|
|
};
|
|
});
|
|
|
|
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
|
|
|
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
|
|
shouldSendCompletedEmail = sendEmail;
|
|
}
|
|
|
|
if (shouldSendCompletedEmail) {
|
|
await io.triggerJob('trigger-send-completed-email', {
|
|
name: 'send.document.completed.email',
|
|
payload: {
|
|
envelopeId,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: envelopeId,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: isRejected
|
|
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
|
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
|
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;
|
|
pdfData: Uint8Array;
|
|
certificateDoc: PDF | null;
|
|
auditLogDoc: PDF | null;
|
|
};
|
|
|
|
/**
|
|
* Normalize, flatten and insert fields into a PDF document.
|
|
*/
|
|
const decorateAndSignPdf = async ({
|
|
envelope,
|
|
envelopeItem,
|
|
envelopeItemFields,
|
|
isRejected,
|
|
rejectionReason,
|
|
pdfData,
|
|
certificateDoc,
|
|
auditLogDoc,
|
|
}: DecorateAndSignPdfOptions) => {
|
|
let pdfDoc = await PDF.load(pdfData);
|
|
|
|
// Normalize and flatten layers that could cause issues with the signature
|
|
pdfDoc.flattenAll();
|
|
// Upgrade to PDF 1.7 for better compatibility with signing
|
|
pdfDoc.upgradeVersion('1.7');
|
|
|
|
// Add rejection stamp if the document is rejected
|
|
if (isRejected) {
|
|
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
|
}
|
|
|
|
if (certificateDoc) {
|
|
await pdfDoc.copyPagesFrom(
|
|
certificateDoc,
|
|
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
|
|
);
|
|
}
|
|
|
|
if (auditLogDoc) {
|
|
await pdfDoc.copyPagesFrom(
|
|
auditLogDoc,
|
|
Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
|
|
);
|
|
}
|
|
|
|
// Handle V1 and legacy insertions.
|
|
if (envelope.internalVersion === 1) {
|
|
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
|
|
|
|
for (const field of envelopeItemFields) {
|
|
if (field.inserted) {
|
|
if (envelope.useLegacyFieldInsertion) {
|
|
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
|
|
} else {
|
|
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should never run into issues with this flatten since all
|
|
// arcoFields are created by pdf-lib itself.
|
|
legacy_pdfLibDoc.getForm().flatten();
|
|
|
|
await pdfDoc.reload(await legacy_pdfLibDoc.save());
|
|
}
|
|
|
|
// Handle V2 envelope insertions.
|
|
if (envelope.internalVersion === 2) {
|
|
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
|
|
|
|
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
|
|
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
|
|
|
if (!page) {
|
|
throw new Error(`Page ${pageNumber} does not exist`);
|
|
}
|
|
|
|
const pageWidth = page.width;
|
|
const pageHeight = page.height;
|
|
|
|
const overlayBytes = await insertFieldInPDFV2({
|
|
pageWidth,
|
|
pageHeight,
|
|
fields,
|
|
});
|
|
|
|
const overlayPdf = await PDF.load(overlayBytes);
|
|
|
|
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
|
|
|
|
// Rotate the page to the orientation that the react-pdf renders on the frontend.
|
|
let translateX = 0;
|
|
let translateY = 0;
|
|
|
|
switch (page.rotation) {
|
|
case 90:
|
|
translateX = pageHeight;
|
|
translateY = 0;
|
|
break;
|
|
case 180:
|
|
translateX = pageWidth;
|
|
translateY = pageHeight;
|
|
break;
|
|
case 270:
|
|
translateX = 0;
|
|
translateY = pageWidth;
|
|
break;
|
|
}
|
|
|
|
// Draw the overlay on the page
|
|
page.drawPage(embeddedPage, {
|
|
x: translateX,
|
|
y: translateY,
|
|
rotate: {
|
|
angle: page.rotation,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Re-flatten the form to handle our checkbox and radio fields that
|
|
// create native arcoFields
|
|
pdfDoc.flattenAll();
|
|
|
|
pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
|
|
|
|
const pdfBytes = await signPdf({ pdf: pdfDoc });
|
|
|
|
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(pdfBytes),
|
|
});
|
|
|
|
return {
|
|
oldDocumentDataId: envelopeItem.documentData.id,
|
|
newDocumentDataId: newDocumentData.id,
|
|
};
|
|
};
|