import path from 'node:path'; import { PDFDocument } from '@cantoo/pdf-lib'; 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 { 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 { groupBy } from 'remeda'; 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 { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } 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 { jobs } from '../../client'; 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) { await tx.envelopeItem.update({ where: { envelopeId: envelope.id, documentDataId: oldDocumentDataId, }, data: { documentDataId: newDocumentDataId, }, }); } 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, }; }); 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, }); let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected; if (isResealing && !isDocumentCompleted(envelopeStatus)) { shouldSendCompletedEmail = sendEmail; } if (shouldSendCompletedEmail) { await jobs.triggerJob({ name: 'send.document.completed.emails', payload: { envelopeId, requestMetadata, }, }); } }; type DecorateAndSignPdfOptions = { envelope: Pick; 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 { documentData: newDocumentData } = await putPdfFileServerSide( { name: `${name}${suffix}`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdfBytes), }, envelopeItem.documentData.initialData, ); return { oldDocumentDataId: envelopeItem.documentData.id, newDocumentDataId: newDocumentData.id, }; };