chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -9,8 +9,8 @@ import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
const pages = pdf.getPages();
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
res.arrayBuffer(),
);
const font = pdf.embedFont(new Uint8Array(fontBytes));
@@ -1,8 +1,7 @@
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
@@ -21,10 +20,7 @@ export type BoundingBox = {
*
* Mutates the PDF in place. Coordinates use bottom-left origin (standard PDF coordinates).
*/
export const whiteoutRegions = (
pdfDoc: PDF,
regions: Array<{ pageIndex: number; bbox: BoundingBox }>,
): void => {
export const whiteoutRegions = (pdfDoc: PDF, regions: Array<{ pageIndex: number; bbox: BoundingBox }>): void => {
const pages = pdfDoc.getPages();
for (const { pageIndex, bbox } of regions) {
@@ -117,7 +113,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
@@ -151,10 +147,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
*
* Accepts optional pre-extracted placeholders to avoid re-parsing the PDF.
*/
export const removePlaceholdersFromPDF = async (
pdf: Buffer,
placeholders?: PlaceholderInfo[],
): Promise<Buffer> => {
export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: PlaceholderInfo[]): Promise<Buffer> => {
const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf));
const pdfDoc = await PDF.load(new Uint8Array(pdf));
@@ -218,8 +211,7 @@ export const convertPlaceholdersToFieldInputs = (
const widthPercent = (p.width / p.pageWidth) * 100;
const heightPercent = (p.height / p.pageHeight) * 100;
const finalHeightPercent =
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(p.recipient, p.placeholder);
@@ -1,8 +1,8 @@
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
@@ -12,15 +12,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 +40,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
messages,
});
const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs];
const auditLogPages = await renderAuditLogs({
envelope,
envelopeOwner,
@@ -1,8 +1,7 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, Field, Recipient, Signature } from '@prisma/client';
import type { DocumentMeta, Envelope, Field, Recipient, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { match } from 'ts-pattern';
@@ -16,7 +15,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<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeOwner: {
@@ -66,22 +72,17 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
(log) => log.type === 'DOCUMENT_SENT',
);
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_OPENED'
].find((log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId);
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs['DOCUMENT_OPENED'].find(
(log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId,
);
const documentRecipientCompleted: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_COMPLETED'
].find(
(log) =>
log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId,
);
].find((log) => log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId);
const documentRecipientRejected: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_REJECTED'
].find(
(log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId,
);
].find((log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId);
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -136,6 +137,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
};
}),
envelopeOwner,
envelopeId: envelope.id,
qrToken: envelope.qrToken,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
@@ -0,0 +1,76 @@
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { PDF } from '@libpdf/core';
import { groupBy } from 'remeda';
import { insertFieldInPDFV2 } from './insert-field-in-pdf-v2';
type GeneratePartialSignedPdfOptions = {
pdfData: Uint8Array;
fields: FieldWithSignature[];
};
/**
* Generates a PDF with all currently-inserted fields burned in. Used to serve
* partially signed envelopes during the `PENDING` window before the seal job
* has had a chance to produce the final sealed PDF.
*
* No PKI signature, no certificate page, no audit log appendix - this is a
* preview of the in-progress envelope, not a final executed document.
*/
export const generatePartialSignedPdf = async ({ pdfData, fields }: GeneratePartialSignedPdfOptions) => {
const pdfDoc = await PDF.load(pdfData);
pdfDoc.flattenAll();
pdfDoc.upgradeVersion('1.7');
const fieldsGroupedByPage = groupBy(fields, (field) => field.page);
for (const [pageNumber, pageFields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
const pageWidth = page.width;
const pageHeight = page.height;
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields: pageFields,
});
const overlayPdf = await PDF.load(overlayBytes);
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
pdfDoc.flattenAll();
return await pdfDoc.save({ useXRefStream: true });
};
+45 -5
View File
@@ -1,4 +1,10 @@
import type { PDFPage } from '@cantoo/pdf-lib';
import type { PDF } from '@libpdf/core';
import { PDF_SIZE_A4_72PPI } from '../../constants/pdf';
const MIN_CERT_PAGE_WIDTH = 300;
const MIN_CERT_PAGE_HEIGHT = 300;
/**
* Gets the effective page size for PDF operations.
@@ -7,12 +13,46 @@ import type { PDFPage } from '@cantoo/pdf-lib';
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
let mediaBox;
let cropBox;
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
try {
mediaBox = page.getMediaBox();
} catch {
// MediaBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
return cropBox;
try {
cropBox = page.getCropBox();
} catch {
// CropBox lookup can fail for malformed PDFs where the entry is not a valid PDFArray.
}
if (mediaBox && cropBox) {
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
}
// If either box is missing or invalid, fall back to MediaBox if available, otherwise CropBox, or default to A4 size.
return mediaBox || cropBox || PDF_SIZE_A4_72PPI;
};
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
if (!lastPage) {
return PDF_SIZE_A4_72PPI;
}
const width = Math.round(lastPage.width);
const height = Math.round(lastPage.height);
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
return PDF_SIZE_A4_72PPI;
}
return { width, height };
};
+37 -2
View File
@@ -1,8 +1,43 @@
import { FieldType } from '@prisma/client';
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
/**
* Ensure all required fonts are registered in the skia-canvas FontLibrary.
*
* Fonts are registered once per process and retained — calling this multiple
* times is a no-op after the first invocation.
*/
export const ensureFontLibrary = () => {
const fontPath = path.join(process.cwd(), 'public/fonts');
if (!FontLibrary.has('Caveat')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
});
}
if (!FontLibrary.has('Inter')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
}
if (!FontLibrary.has('Noto Sans')) {
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
}
};
type RecipientPlaceholderInfo = {
email: string;
@@ -1,17 +1,6 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import type { PDFDocument, PDFFont, PDFTextField } from '@cantoo/pdf-lib';
import {
RotationTypes,
TextAlignment,
degrees,
radiansToDegrees,
rgb,
setFontAndSize,
} from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { degrees, RotationTypes, radiansToDegrees, rgb, setFontAndSize, TextAlignment } from '@cantoo/pdf-lib';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
@@ -21,6 +10,9 @@ import {
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import { match, P } from 'ts-pattern';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import {
@@ -37,12 +29,8 @@ import { getPageSize } from './get-page-size';
export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
res.arrayBuffer(),
),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
res.arrayBuffer(),
),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
@@ -109,13 +97,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, debugX, debugY, pageRotationInDegrees);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
@@ -187,9 +169,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const longestLineInTextForWidth = signatureText.split('\n').sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
@@ -401,9 +381,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
* - True = text will overflow downwards.
* - False = text will overflow sideways.
*/
const isMultiline =
field.type === FieldType.TEXT &&
(textWidth > fieldWidth || field.customText.includes('\n'));
const isMultiline = field.type === FieldType.TEXT && (textWidth > fieldWidth || field.customText.includes('\n'));
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8;
@@ -1,14 +1,12 @@
// sort-imports-ignore
import '../konva/skia-backend';
import Konva from 'konva';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
type InsertFieldInPDFV2Options = {
pageWidth: number;
@@ -16,21 +14,8 @@ type InsertFieldInPDFV2Options = {
fields: FieldWithSignature[];
};
export const insertFieldInPDFV2 = async ({
pageWidth,
pageHeight,
fields,
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
});
export const insertFieldInPDFV2 = async ({ pageWidth, pageHeight, fields }: InsertFieldInPDFV2Options) => {
ensureFontLibrary();
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
let layer: Konva.Layer | null = new Konva.Layer();
@@ -1,10 +1,6 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import type { PDFDocument } from '@cantoo/pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { degrees, RotationTypes, radiansToDegrees, rgb } from '@cantoo/pdf-lib';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
@@ -14,6 +10,9 @@ import {
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import { match, P } from 'ts-pattern';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import {
@@ -30,12 +29,8 @@ import { getPageSize } from './get-page-size';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
res.arrayBuffer(),
),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
res.arrayBuffer(),
),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
@@ -95,13 +90,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, debugX, debugY, pageRotationInDegrees);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
@@ -173,9 +162,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const longestLineInTextForWidth = signatureText.split('\n').sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
@@ -314,9 +301,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const longestLineInTextForWidth = field.customText.split('\n').sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
@@ -346,13 +331,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
const adjustedPosition = adjustPositionForRotation(pageWidth, pageHeight, textX, textY, pageRotationInDegrees);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
@@ -1,26 +1,24 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, RecipientRole } from '@prisma/client';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match } from 'ts-pattern';
import { P } from 'ts-pattern';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { DOCUMENT_STATUS } from '../../constants/document';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs';
import { ensureFontLibrary } from './helpers';
export type AuditLogRecipient = {
id: number;
@@ -30,7 +28,7 @@ export type AuditLogRecipient = {
};
type GenerateAuditLogsOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -57,7 +55,7 @@ const textXs = 8;
const fontMedium = '500';
const pageTopMargin = 60;
const pageBottomMargin = 15;
const pageBottomMargin = 27;
const contentMaxWidth = 768;
const rowPadding = 10;
const titleFontSize = 18;
@@ -168,7 +166,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
};
type RenderOverviewCardOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -219,9 +217,7 @@ const renderOverviewCard = (options: RenderOverviewCardOptions) => {
const statusLabel = renderOverviewCardLabels({
label: i18n._(msg`Status`),
text: i18n
._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description)
.toUpperCase(),
text: i18n._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description).toUpperCase(),
width: columnWidth,
});
const timeZoneLabel = renderOverviewCardLabels({
@@ -352,9 +348,7 @@ const renderRow = (options: RenderRowOptions) => {
const auditLogTimestampText = new Konva.Text({
x: columnWidth + columnSpacing,
width: columnWidth,
text: DateTime.fromJSDate(auditLog.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat),
text: DateTime.fromJSDate(auditLog.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale).toLocaleString(dateFormat),
fontFamily: 'Inter',
align: 'right',
fontSize: textSm,
@@ -575,13 +569,7 @@ export async function renderAuditLogs({
i18n,
hidePoweredBy,
}: GenerateAuditLogsOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
ensureFontLibrary();
const minimumMargin = 10;
@@ -601,7 +589,7 @@ export async function renderAuditLogs({
const groupedRows = groupRowsIntoPages({
auditLogs,
maxHeight: pageHeight,
maxHeight: pageHeight - pageBottomMargin,
contentWidth,
i18n,
overviewCard,
@@ -628,6 +616,16 @@ export async function renderAuditLogs({
stage.destroyChildren();
const page = new Konva.Layer();
const footerText = new Konva.Text({
x: margin,
y: pageHeight - textXs - 10,
text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`,
fontFamily: 'Inter',
fontSize: textXs,
fill: textMutedForegroundLight,
});
page.add(footerText);
page.add(pageGroup);
// Add branding on the last page if there is space.
@@ -663,6 +661,16 @@ export async function renderAuditLogs({
y: pageTopMargin,
} satisfies Partial<Konva.GroupConfig>);
const overflowFooterText = new Konva.Text({
x: margin,
y: pageHeight - textXs - 10,
text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`,
fontFamily: 'Inter',
fontSize: textXs,
fill: textMutedForegroundLight,
});
page.add(overflowFooterText);
page.add(brandingGroup);
stage.add(page);
@@ -693,10 +701,7 @@ const getAuditLogIndicatorColor = (type: string) =>
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => '#ef4444') // bg-red-500
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => '#f97316') // bg-orange-500
.with(
P.union(
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
),
P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
() => '#3b82f6', // bg-blue-500
)
.otherwise(() => '#f1f5f9'); // bg-muted
@@ -1,27 +1,23 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import type { RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '../../constants/recipient-roles';
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
import { ensureFontLibrary } from './helpers';
type ColumnWidths = [number, number, number];
@@ -49,6 +45,7 @@ export type CertificateRecipient = {
type GenerateCertificateOptions = {
recipients: CertificateRecipient[];
envelopeId: string;
qrToken: string | null;
hidePoweredBy: boolean;
i18n: I18n;
@@ -78,6 +75,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;
@@ -87,7 +85,7 @@ const columnWidthPercentages = [30, 30, 40];
const rowPadding = 12;
const tableHeaderHeight = 38;
const pageTopMargin = 72;
const pageBottomMargin = 12;
const pageBottomMargin = 24;
const contentMaxWidth = 768;
const titleFontSize = 18;
@@ -97,6 +95,8 @@ type RenderLabelAndTextOptions = {
text: string;
width: number;
y?: number;
labelFill?: string;
valueFill?: string;
};
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
@@ -106,13 +106,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 +127,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
width: width - label.width(),
fontFamily: 'Inter',
text: options.text,
fill: textMutedForeground,
fill: valueFill,
wrap: 'char',
fontSize: textSm,
});
@@ -269,6 +272,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 +318,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 +350,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 +384,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 +396,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 +410,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 +446,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 +478,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);
}
@@ -693,6 +714,7 @@ const renderTables = (options: RenderTablesOptions) => {
export async function renderCertificate({
recipients,
envelopeId,
qrToken,
hidePoweredBy,
i18n,
@@ -700,13 +722,7 @@ export async function renderCertificate({
pageWidth,
pageHeight,
}: GenerateCertificateOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
ensureFontLibrary();
const minimumMargin = 10;
@@ -784,6 +800,16 @@ export async function renderCertificate({
}
}
const footerText = new Konva.Text({
x: margin,
y: pageHeight - textXs - 10,
text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`,
fontFamily: 'Inter',
fontSize: textXs,
fill: textMutedForegroundLight,
});
page.add(footerText);
page.add(group);
stage.add(page);
@@ -802,6 +828,16 @@ export async function renderCertificate({
y: pageTopMargin / 2, // Less padding since there's nothing else on this page.
} satisfies Partial<Konva.GroupConfig>);
const overflowFooterText = new Konva.Text({
x: margin,
y: pageHeight - textXs - 10,
text: `${i18n._(msg`Envelope ID`)}: ${envelopeId}`,
fontFamily: 'Inter',
fontSize: textXs,
fill: textMutedForegroundLight,
});
page.add(overflowFooterText);
page.add(brandingGroup);
stage.add(page);