mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
284 lines
8.3 KiB
TypeScript
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,
|
|
});
|
|
};
|