import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, WebhookTriggerEvents, } from '@prisma/client'; import { nanoid } from 'nanoid'; import path from 'node:path'; import { PDFDocument } from 'pdf-lib'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; import { signPdf } from '@documenso/signing'; import { ZWebhookDocumentSchema, mapEnvelopeToWebhookDocumentPayload, } from '../../types/webhook-payload'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery, } from '../../utils/envelope'; import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenForm } from '../pdf/flatten-form'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { getTeamSettings } from '../team/get-team-settings'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { id: EnvelopeIdOptions; sendEmail?: boolean; isResealing?: boolean; requestMetadata?: RequestMetadata; }; export const sealDocument = async ({ id, sendEmail = true, isResealing = false, requestMetadata, }: SealDocumentOptions) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), include: { envelopeItems: { select: { id: true, documentData: true, }, include: { field: { include: { signature: true, }, }, }, }, documentMeta: true, recipients: { where: { role: { not: RecipientRole.CC, }, }, }, }, }); // Todo: Envelopes const envelopeItemToSeal = envelope.envelopeItems[0]; // Todo: Envelopes if (envelope.envelopeItems.length !== 1 || !envelopeItemToSeal) { throw new Error(`Document ${envelope.id} needs exactly 1 envelope item`); } const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const documentData = envelopeItemToSeal.documentData; const fields = envelopeItemToSeal.field; // Todo: Envelopes - This only takes in the first envelope item fields. const recipients = envelope.recipients; const settings = await getTeamSettings({ userId: envelope.userId, teamId: envelope.teamId, }); // Determine if the document has been rejected by checking if any recipient has rejected it const rejectedRecipient = recipients.find( (recipient) => recipient.signingStatus === SigningStatus.REJECTED, ); const isRejected = Boolean(rejectedRecipient); // Get the rejection reason from the rejected recipient const rejectionReason = rejectedRecipient?.rejectionReason ?? ''; // If the document is not rejected, ensure all recipients have signed if ( !isRejected && recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED) ) { throw new Error(`Envelope ${envelope.id} has unsigned recipients`); } // 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. documentData.data = documentData.initialData; } // !: Need to write the fields onto the document as a hard copy const pdfData = await getFileServerSide(documentData); const certificateData = settings.includeSigningCertificate ? await getCertificatePdf({ documentId: legacyDocumentId, language: envelope.documentMeta.language, }).catch((e) => { console.log('Failed to get certificate PDF'); console.error(e); return null; }) : null; const auditLogData = settings.includeAuditLog ? await getAuditLogsPdf({ documentId: legacyDocumentId, language: envelope.documentMeta.language, }).catch((e) => { console.log('Failed to get audit logs PDF'); console.error(e); return null; }) : null; const doc = await PDFDocument.load(pdfData); // Normalize and flatten layers that could cause issues with the signature normalizeSignatureAppearances(doc); await flattenForm(doc); flattenAnnotations(doc); // Add rejection stamp if the document is rejected if (isRejected && rejectionReason) { await addRejectionStampToPdf(doc, rejectionReason); } if (certificateData) { const certificate = await PDFDocument.load(certificateData); const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); certificatePages.forEach((page) => { doc.addPage(page); }); } if (auditLogData) { const auditLog = await PDFDocument.load(auditLogData); const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices()); auditLogPages.forEach((page) => { doc.addPage(page); }); } for (const field of fields) { envelope.useLegacyFieldInsertion ? await legacy_insertFieldInPDF(doc, field) : await insertFieldInPDF(doc, field); } // Re-flatten post-insertion to handle fields that create arcoFields await flattenForm(doc); const pdfBytes = await doc.save(); const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); // Todo: Envelopes use EnvelopeItem title instead. const { name } = path.parse(envelope.title); // Add suffix based on document status const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf'; const { data: newData } = await putPdfFileServerSide({ name: `${name}${suffix}`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdfBuffer), }); const postHog = PostHogServerClient(); if (postHog) { postHog.capture({ distinctId: nanoid(), event: 'App: Document Sealed', properties: { documentId: envelope.id, isRejected, }, }); } await prisma.$transaction(async (tx) => { await tx.envelope.update({ where: { id: envelope.id, }, data: { status: isRejected ? DocumentStatus.REJECTED : 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, envelopeId: envelope.id, requestMetadata, user: null, data: { transactionId: nanoid(), ...(isRejected ? { isRejected: true, rejectionReason } : {}), }, }), }); }); if (sendEmail && !isResealing) { await sendCompletedEmail({ id, requestMetadata }); } const updatedDocument = await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id, }, include: { documentMeta: true, recipients: true, }, }); await triggerWebhook({ event: isRejected ? WebhookTriggerEvents.DOCUMENT_REJECTED : WebhookTriggerEvents.DOCUMENT_COMPLETED, data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)), userId: envelope.userId, teamId: envelope.teamId ?? undefined, }); };