mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
Rework: - Field styling to improve visibility - Field insertions, better alignment, centering and overflows ## Changes General changes: - Set default text alignment to left if no meta found - Reduce borders and rings around fields to allow smaller fields - Removed lots of redundant duplicated code surrounding field rendering - Make fields more consistent across viewing, editing and signing - Add more transparency to fields to allow users to see under fields - No more optional/required/etc colors when signing, required fields will be highlighted as orange when form is "validating" Highlighted internal changes: - Utilize native PDF fields to insert text, instead of drawing text - Change font auto scaling to only apply to when the height overflows AND no custom font is set ⚠️ Multiline changes: Multi line is enabled for a field under these conditions 1. Field content exceeds field width 2. Field includes a new line 3. Field type is TEXT ## [BEFORE] Field UI Signing  ## [AFTER] Field UI Signing  ## [BEFORE] Signing a checkbox   ## [AFTER] Signing a checkbox   ## [BEFORE] What a 2nd recipient sees once someone else signed a document  ## [AFTER] What a 2nd recipient sees once someone else signed a document  ## **[BEFORE]** Inserting fields  ## **[AFTER]** Inserting fields  ## Overflows, multilines and field alignments testing Debugging borders: - Red border = The original field placement without any modifications - Blue border = The available space to overflow ### Single line overflows and field alignments This is left aligned fields, overflow will always go to the end of the page and will not wrap  This is center aligned fields, the max width is the closest edge to the page * 2  This is right aligned text, the width will extend all the way to the left hand side of the page  ### Multiline line overflows and field alignments These are text fields that can be overflowed  Another example of left aligned text overflows with more text 
244 lines
7.0 KiB
TypeScript
244 lines
7.0 KiB
TypeScript
import { DocumentStatus, 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,
|
|
mapDocumentToWebhookDocumentPayload,
|
|
} 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 { 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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|
import { sendCompletedEmail } from './send-completed-email';
|
|
|
|
export type SealDocumentOptions = {
|
|
documentId: number;
|
|
sendEmail?: boolean;
|
|
isResealing?: boolean;
|
|
requestMetadata?: RequestMetadata;
|
|
};
|
|
|
|
export const sealDocument = async ({
|
|
documentId,
|
|
sendEmail = true,
|
|
isResealing = false,
|
|
requestMetadata,
|
|
}: SealDocumentOptions) => {
|
|
const document = await prisma.document.findFirstOrThrow({
|
|
where: {
|
|
id: documentId,
|
|
},
|
|
include: {
|
|
documentData: true,
|
|
documentMeta: true,
|
|
recipients: true,
|
|
team: {
|
|
select: {
|
|
teamGlobalSettings: {
|
|
select: {
|
|
includeSigningCertificate: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const { documentData } = document;
|
|
|
|
if (!documentData) {
|
|
throw new Error(`Document ${document.id} has no document data`);
|
|
}
|
|
|
|
const recipients = await prisma.recipient.findMany({
|
|
where: {
|
|
documentId: document.id,
|
|
role: {
|
|
not: RecipientRole.CC,
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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(`Document ${document.id} has unsigned recipients`);
|
|
}
|
|
|
|
const fields = await prisma.field.findMany({
|
|
where: {
|
|
documentId: document.id,
|
|
},
|
|
include: {
|
|
signature: true,
|
|
},
|
|
});
|
|
|
|
// Skip the field check if the document is rejected
|
|
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
|
throw new Error(`Document ${document.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 =
|
|
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
|
|
? await getCertificatePdf({
|
|
documentId,
|
|
language: document.documentMeta?.language,
|
|
}).catch(() => null)
|
|
: null;
|
|
|
|
const doc = await PDFDocument.load(pdfData);
|
|
|
|
// Normalize and flatten layers that could cause issues with the signature
|
|
normalizeSignatureAppearances(doc);
|
|
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);
|
|
});
|
|
}
|
|
|
|
for (const field of fields) {
|
|
document.useLegacyFieldInsertion
|
|
? await legacy_insertFieldInPDF(doc, field)
|
|
: await insertFieldInPDF(doc, field);
|
|
}
|
|
|
|
// Re-flatten post-insertion to handle fields that create arcoFields
|
|
flattenForm(doc);
|
|
|
|
const pdfBytes = await doc.save();
|
|
|
|
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
|
|
|
const { name } = path.parse(document.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: document.id,
|
|
isRejected,
|
|
},
|
|
});
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.document.update({
|
|
where: {
|
|
id: document.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,
|
|
documentId: document.id,
|
|
requestMetadata,
|
|
user: null,
|
|
data: {
|
|
transactionId: nanoid(),
|
|
...(isRejected ? { isRejected: true, rejectionReason } : {}),
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
if (sendEmail && !isResealing) {
|
|
await sendCompletedEmail({ documentId, requestMetadata });
|
|
}
|
|
|
|
const updatedDocument = await prisma.document.findFirstOrThrow({
|
|
where: {
|
|
id: document.id,
|
|
},
|
|
include: {
|
|
documentData: true,
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: isRejected
|
|
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
|
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
|
userId: document.userId,
|
|
teamId: document.teamId ?? undefined,
|
|
});
|
|
};
|