From 0d65693d5570b7106d60740d37d03d22d60d740a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 12 Feb 2026 16:06:43 +1100 Subject: [PATCH] fix: highlight rejected certificate text (#2478) ## Description - Update the rejected certificate so that is it more clear on who rejected the document. - Updated the audit log generation so that the completed audit log is included ### Before image ### After Note that the order of the recipient is different in this case image --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lucas Smith --- .../internal/seal-document.handler.ts | 43 +++++++++++++------ .../server-only/pdf/generate-audit-log-pdf.ts | 18 ++++++-- .../pdf/generate-certificate-pdf.ts | 9 +++- .../lib/server-only/pdf/render-audit-logs.ts | 4 +- .../lib/server-only/pdf/render-certificate.ts | 38 +++++++++++++--- 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 242e1db5d..2e37b3039 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -29,6 +29,7 @@ import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf 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 type { TDocumentAuditLog } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { ZWebhookDocumentSchema, @@ -169,12 +170,38 @@ export const run = async ({ }); } + // 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; + let certificateDoc: PDF | null = null; let auditLogDoc: PDF | null = null; if (settings.includeSigningCertificate || settings.includeAuditLog) { + const additionalAuditLogs = [ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + { + ...envelopeCompletedAuditLog, + id: '', + createdAt: new Date(), + } as TDocumentAuditLog, + ]; + const certificatePayload = { - envelope, + envelope: { + ...envelope, + status: finalEnvelopeStatus, + }, recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients. fields, language: envelope.documentMeta.language, @@ -185,6 +212,7 @@ export const run = async ({ envelopeItems: envelopeItems.map((item) => item.title), pageWidth: PDF_SIZE_A4_72PPI.width, pageHeight: PDF_SIZE_A4_72PPI.height, + additionalAuditLogs, }; // Use Playwright-based PDF generation if enabled, otherwise use Konva-based generation. @@ -263,22 +291,13 @@ export const run = async ({ id: envelope.id, }, data: { - status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED, + status: finalEnvelopeStatus, completedAt: new Date(), }, }); 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: rejectionReason } : {}), - }, - }), + data: envelopeCompletedAuditLog, }); }); diff --git a/packages/lib/server-only/pdf/generate-audit-log-pdf.ts b/packages/lib/server-only/pdf/generate-audit-log-pdf.ts index aac8245db..c1071b9bf 100644 --- a/packages/lib/server-only/pdf/generate-audit-log-pdf.ts +++ b/packages/lib/server-only/pdf/generate-audit-log-pdf.ts @@ -1,6 +1,7 @@ import { PDF } from '@libpdf/core'; import { i18n } from '@lingui/core'; +import { type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; import { prisma } from '@documenso/prisma'; import { ZSupportedLanguageCodeSchema } from '../../constants/i18n'; @@ -12,15 +13,24 @@ import { renderAuditLogs } from './render-audit-logs'; type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & { envelopeItems: string[]; + additionalAuditLogs?: TDocumentAuditLog[]; }; export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => { - const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } = - options; + const { + envelope, + envelopeOwner, + envelopeItems, + recipients, + language, + pageWidth, + pageHeight, + additionalAuditLogs = [], + } = options; const documentLanguage = ZSupportedLanguageCodeSchema.parse(language); - const [organisationClaim, auditLogs, messages] = await Promise.all([ + const [organisationClaim, partialAuditLogs, messages] = await Promise.all([ getOrganisationClaimByTeamId({ teamId: envelope.teamId }), getAuditLogs(envelope.id), getTranslations(documentLanguage), @@ -31,6 +41,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) = messages, }); + const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs]; + const auditLogPages = await renderAuditLogs({ envelope, envelopeOwner, diff --git a/packages/lib/server-only/pdf/generate-certificate-pdf.ts b/packages/lib/server-only/pdf/generate-certificate-pdf.ts index ff12edc91..4fa66d78f 100644 --- a/packages/lib/server-only/pdf/generate-certificate-pdf.ts +++ b/packages/lib/server-only/pdf/generate-certificate-pdf.ts @@ -16,7 +16,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c import { renderCertificate } from './render-certificate'; export type GenerateCertificatePdfOptions = { - envelope: Envelope & { + /** + * Note: completedAt is not included since it's not real at this point in time. + * + * If we actually need it here in the future, we will need to preserve the + * completedAt value and pass it to the final `envelope.update` function when + * the document is initially sealed. + */ + envelope: Omit & { documentMeta: DocumentMeta; }; envelopeOwner: { diff --git a/packages/lib/server-only/pdf/render-audit-logs.ts b/packages/lib/server-only/pdf/render-audit-logs.ts index 2cd5d307c..53874e96e 100644 --- a/packages/lib/server-only/pdf/render-audit-logs.ts +++ b/packages/lib/server-only/pdf/render-audit-logs.ts @@ -30,7 +30,7 @@ export type AuditLogRecipient = { }; type GenerateAuditLogsOptions = { - envelope: Envelope & { + envelope: Omit & { documentMeta: DocumentMeta; }; envelopeItems: string[]; @@ -168,7 +168,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions) }; type RenderOverviewCardOptions = { - envelope: Envelope & { + envelope: Omit & { documentMeta: DocumentMeta; }; envelopeItems: string[]; diff --git a/packages/lib/server-only/pdf/render-certificate.ts b/packages/lib/server-only/pdf/render-certificate.ts index 43209b9c7..9991e447a 100644 --- a/packages/lib/server-only/pdf/render-certificate.ts +++ b/packages/lib/server-only/pdf/render-certificate.ts @@ -78,6 +78,7 @@ const getDevice = (userAgent?: string | null): string => { const textMutedForegroundLight = '#929DAE'; const textForeground = '#000'; const textMutedForeground = '#64748B'; +const textRejectedRed = '#dc2626'; const textBase = 10; const textSm = 9; const textXs = 8; @@ -97,6 +98,8 @@ type RenderLabelAndTextOptions = { text: string; width: number; y?: number; + labelFill?: string; + valueFill?: string; }; const renderLabelAndText = (options: RenderLabelAndTextOptions) => { @@ -106,13 +109,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => { y, }); + const labelFill = options.labelFill ?? textMutedForeground; + const valueFill = options.valueFill ?? textMutedForeground; + const label = new Konva.Text({ x: 0, y: 0, text: `${options.label}: `, fontStyle: fontMedium, fontFamily: 'Inter', - fill: textMutedForeground, + fill: labelFill, fontSize: textSm, }); @@ -124,7 +130,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => { width: width - label.width(), fontFamily: 'Inter', text: options.text, - fill: textMutedForeground, + fill: valueFill, wrap: 'char', fontSize: textSm, }); @@ -269,6 +275,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => { const columnWidth = width - columnPadding; + const isRejected = Boolean(recipient.logs.rejected); + if (recipient.signatureField?.secondaryId) { // Signature container with green border const signatureContainer = new Konva.Group({ x: 0, y: 0 }); @@ -313,7 +321,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => { signatureContainer.add(typedSig); } - column.add(signatureContainer); + // Do not add the signature container for rejected recipients. + if (!isRejected) { + column.add(signatureContainer); + } const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight); @@ -342,7 +353,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => { // Signature ID const sigIdLabel = new Konva.Text({ x: 0, - y: signatureHeight + 10, + y: isRejected ? 0 : signatureHeight + 10, text: `${i18n._(msg`Signature ID`)}:`, fill: textMutedForeground, width: columnWidth, @@ -376,9 +387,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => { column.add(naText); } + const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed; + const ipLabelAndText = renderLabelAndText({ label: i18n._(msg`IP Address`), - text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`), + text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`), width, y: column.getClientRect().height + 6, }); @@ -386,7 +399,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => { const deviceLabelAndText = renderLabelAndText({ label: i18n._(msg`Device`), - text: getDevice(recipient.logs.completed?.userAgent), + text: getDevice(relevantLog?.userAgent), width, y: column.getClientRect().height + 6, }); @@ -400,7 +413,14 @@ const renderColumnThree = (options: RenderColumnOptions) => { const column = new Konva.Group(); - const itemsToRender = [ + type DetailItem = { + label: string; + value: string; + labelFill?: string; + valueFill?: string; + }; + + const itemsToRender: DetailItem[] = [ { label: i18n._(msg`Sent`), value: recipient.logs.emailed @@ -429,6 +449,8 @@ const renderColumnThree = (options: RenderColumnOptions) => { value: DateTime.fromJSDate(recipient.logs.rejected.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), + labelFill: textRejectedRed, + valueFill: textRejectedRed, }); } else { itemsToRender.push({ @@ -459,6 +481,8 @@ const renderColumnThree = (options: RenderColumnOptions) => { text: item.value, width, y: column.getClientRect().height + (index === 0 ? 0 : 8), + labelFill: item.labelFill, + valueFill: item.valueFill, }); column.add(labelAndText); }