Files
documenso/packages/lib/server-only/document/seal-document.ts

284 lines
8.3 KiB
TypeScript

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,
});
};