feat: autoplace fields from placeholders (#2111)

This PR introduces automatic detection and placement of fields and
recipients based on PDF placeholders.

The placeholders have the following structure:
- `{{fieldType,recipientPosition,fieldMeta}}` 
- `{{text,r1,required=true,textAlign=right,fontSize=50}}`

When the user uploads a PDF document containing such placeholders, they
get converted automatically to Documenso fields and assigned to
recipients.
This commit is contained in:
Catalin Pit
2026-01-29 04:13:45 +02:00
committed by GitHub
parent d77f81163b
commit d18dcb4d60
22 changed files with 2045 additions and 50 deletions
@@ -10,6 +10,9 @@ import {
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -68,7 +71,12 @@ export type CreateEnvelopeOptions = {
type: EnvelopeType;
title: string;
externalId?: string;
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
}[];
formValues?: TDocumentFormValues;
userTimezone?: string;
@@ -164,8 +172,7 @@ export const createEnvelope = async ({
});
}
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
let envelopeItems = data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
@@ -298,7 +305,7 @@ export const createEnvelope = async ({
const delegatedOwner = await getValidatedDelegatedOwner();
const envelopeOwnerId = delegatedOwner?.id ?? userId;
return await prisma.$transaction(async (tx) => {
const createdEnvelope = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -423,6 +430,124 @@ export const createEnvelope = async ({
}),
);
// Create fields from PDF placeholders (extracted at upload time).
const itemsWithPlaceholders = envelopeItems.filter(
(item) => item.placeholders && item.placeholders.length > 0,
);
if (itemsWithPlaceholders.length > 0) {
// Collect all unique recipient placeholder references (e.g. "r1", "r2").
const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []);
const uniqueRecipientRefs = new Map<number, string>();
for (const p of allPlaceholders) {
const match = p.recipient.match(/^r(\d+)$/i);
if (match) {
const index = Number(match[1]);
if (!uniqueRecipientRefs.has(index)) {
uniqueRecipientRefs.set(index, `Recipient ${index}`);
}
}
}
// Fetch existing recipients (may have been created above from data.recipients or defaults).
let availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
const shouldCreatePlaceholderRecipients =
(!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0;
// If recipients were not provided, create placeholder recipients even when defaults exist.
if (shouldCreatePlaceholderRecipients) {
const existingRecipientEmails = new Set(
availableRecipients.map((recipient) => recipient.email.toLowerCase()),
);
const placeholderRecipients = Array.from(
uniqueRecipientRefs.entries(),
([recipientIndex, name]) => ({
envelopeId: envelope.id,
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
}),
).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
if (placeholderRecipients.length > 0) {
await tx.recipient.createMany({
data: placeholderRecipients,
});
// eslint-disable-next-line require-atomic-updates
availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
}
}
for (const item of itemsWithPlaceholders) {
const envelopeItem = envelope.envelopeItems.find(
(ei) => ei.documentDataId === item.documentDataId,
);
if (!envelopeItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
item.placeholders ?? [],
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
data.recipients && data.recipients.length > 0
? data.recipients.map((r) => {
const found = availableRecipients.find((cr) => cr.email === r.email);
if (!found) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient not found for email: ${r.email}`,
});
}
return found;
})
: undefined,
availableRecipients,
),
envelopeItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,
@@ -432,8 +557,12 @@ export const createEnvelope = async ({
recipients: true,
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
@@ -491,4 +620,6 @@ export const createEnvelope = async ({
return createdEnvelope;
});
return createdEnvelope;
};
@@ -1,8 +1,11 @@
import { PDF } from '@libpdf/core';
import { EnvelopeType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
@@ -11,30 +14,53 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
type CoordinatePosition = {
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type PlaceholderPosition = {
placeholder: string;
width?: number;
height?: number;
/**
* When true, creates a field at every occurrence of the placeholder in the PDF.
* When false or omitted, only the first occurrence is used.
*/
matchAll?: boolean;
};
type FieldPosition = CoordinatePosition | PlaceholderPosition;
export type CreateEnvelopeFieldInput = TFieldAndMeta & {
/**
* The ID of the item to insert the fields into.
*
* If blank, the first item will be used.
*/
envelopeItemId?: string;
recipientId: number;
} & FieldPosition;
export interface CreateEnvelopeFieldsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
fields: (TFieldAndMeta & {
/**
* The ID of the item to insert the fields into.
*
* If blank, the first item will be used.
*/
envelopeItemId?: string;
recipientId: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
fields: CreateEnvelopeFieldInput[];
requestMetadata: ApiRequestMetadata;
}
const isPlaceholderPosition = (position: FieldPosition): position is PlaceholderPosition => {
return 'placeholder' in position;
};
export const createEnvelopeFields = async ({
userId,
teamId,
@@ -55,8 +81,8 @@ export const createEnvelopeFields = async ({
recipients: true,
fields: true,
envelopeItems: {
select: {
id: true,
include: {
documentData: true,
},
},
},
@@ -82,8 +108,33 @@ export const createEnvelopeFields = async ({
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const hasPlaceholderFields = fields.some((field) => isPlaceholderPosition(field));
/*
Cache of loaded PDF documents keyed by envelope item ID.
Only loaded when at least one field uses placeholder positioning.
We keep the full PDF objects so we can both read text and draw white boxes
over resolved placeholders before saving back.
*/
const pdfCache = new Map<string, PDF>();
if (hasPlaceholderFields) {
for (const item of envelope.envelopeItems) {
const bytes = await getFileServerSide(item.documentData);
const pdfDoc = await PDF.load(new Uint8Array(bytes));
pdfCache.set(item.id, pdfDoc);
}
}
/*
Collect placeholder bounding boxes that need to be whited out, grouped by
envelope item ID. Populated during field resolution below.
*/
const placeholderWhiteouts = new Map<string, Array<{ pageIndex: number; bbox: BoundingBox }>>();
// Field validation and placeholder resolution.
const validatedFields = fields.flatMap((field) => {
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
// The item to attach the fields to MUST belong to the document.
@@ -111,10 +162,84 @@ export const createEnvelopeFields = async ({
});
}
const envelopeItemId = field.envelopeItemId || firstEnvelopeItem.id;
/*
Resolve field position(s). Placeholder fields are resolved by searching the
PDF text for the placeholder string and using its bounding box.
When matchAll is true, all occurrences produce fields.
*/
if (isPlaceholderPosition(field)) {
const pdfDoc = pdfCache.get(envelopeItemId);
if (!pdfDoc) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Could not load PDF for envelope item ${envelopeItemId}`,
});
}
const matches = pdfDoc.findText(field.placeholder);
if (matches.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Placeholder "${field.placeholder}" not found in PDF`,
});
}
const matchesToProcess = field.matchAll ? matches : [matches[0]];
const pages = pdfDoc.getPages();
return matchesToProcess.map((match) => {
const page = pages[match.pageIndex];
/*
Record this placeholder's bounding box for whiteout. The bbox is in
the original PDF coordinate system (points, bottom-left origin).
*/
if (!placeholderWhiteouts.has(envelopeItemId)) {
placeholderWhiteouts.set(envelopeItemId, []);
}
placeholderWhiteouts.get(envelopeItemId)!.push({
pageIndex: match.pageIndex,
bbox: match.bbox,
});
/*
Convert point-based coordinates (bottom-left origin) to percentage-based
coordinates (top-left origin) matching the system's field coordinate format.
*/
const topLeftY = page.height - match.bbox.y - match.bbox.height;
const widthPercent = field.width ?? (match.bbox.width / page.width) * 100;
const heightPercent = field.height ?? (match.bbox.height / page.height) * 100;
return {
type: field.type,
fieldMeta: field.fieldMeta,
recipientId: field.recipientId,
envelopeItemId,
recipientEmail: recipient.email,
page: match.pageIndex + 1,
positionX: (match.bbox.x / page.width) * 100,
positionY: (topLeftY / page.height) * 100,
width: widthPercent,
height: heightPercent,
};
});
}
return {
...field,
envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided.
type: field.type,
fieldMeta: field.fieldMeta,
recipientId: field.recipientId,
envelopeItemId,
recipientEmail: recipient.email,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
};
});
@@ -162,6 +287,39 @@ export const createEnvelopeFields = async ({
return newlyCreatedFields;
});
/*
Draw white rectangles over each resolved placeholder in the PDF to hide the
placeholder text, then persist the modified PDFs back to document storage.
*/
for (const [envelopeItemId, whiteouts] of placeholderWhiteouts) {
const pdfDoc = pdfCache.get(envelopeItemId);
if (!pdfDoc) {
continue;
}
whiteoutRegions(pdfDoc, whiteouts);
const modifiedPdfBytes = await pdfDoc.save();
const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
if (!envelopeItem) {
continue;
}
const newDocumentData = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
});
await prisma.envelopeItem.update({
where: { id: envelopeItemId },
data: { documentDataId: newDocumentData.id },
});
}
return {
fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
@@ -0,0 +1,237 @@
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;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
export type BoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
/**
* Draw white rectangles over specified regions in a loaded PDF document.
*
* 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 => {
const pages = pdfDoc.getPages();
for (const { pageIndex, bbox } of regions) {
const page = pages[pageIndex];
page.drawRectangle({
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
};
export type PlaceholderInfo = {
placeholder: string;
recipient: string;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type FieldToCreate = TFieldAndMeta & {
envelopeItemId?: string;
recipientId: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
for (const page of pdfDoc.getPages()) {
const pageWidth = page.width;
const pageHeight = page.height;
const matches = page.findText(PLACEHOLDER_REGEX);
for (const match of matches) {
const placeholder = match.text;
/*
Extract the inner content from the placeholder match.
E.g. '{{SIGNATURE, r1, required=true}}' -> 'SIGNATURE, r1, required=true'
*/
const innerMatch = placeholder.match(/^\{\{([^}]+)\}\}$/);
if (!innerMatch) {
continue;
}
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
try {
fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
} catch {
// Skip placeholders with unrecognized field types.
continue;
}
/*
A recipient identifier (e.g. "r1", "R2") is required for auto-placement.
Placeholders without an explicit recipient like {{name}} are reserved for
future API use where callers can reference a placeholder by name with
optional dimensions instead of absolute coordinates.
*/
if (!recipientOrMeta || !/^r\d+$/i.test(recipientOrMeta)) {
continue;
}
const recipient = recipientOrMeta;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
LibPDF returns bbox in points with bottom-left origin.
Convert Y to top-left origin for consistency with the rest of the system.
*/
const topLeftY = pageHeight - match.bbox.y - match.bbox.height;
placeholders.push({
placeholder,
recipient,
fieldAndMeta,
page: page.index + 1,
x: match.bbox.x,
y: topLeftY,
width: match.bbox.width,
height: match.bbox.height,
pageWidth,
pageHeight,
});
}
}
return placeholders;
};
/**
* Draw white rectangles over placeholder text in a PDF.
*
* Accepts optional pre-extracted placeholders to avoid re-parsing the PDF.
*/
export const removePlaceholdersFromPDF = async (
pdf: Buffer,
placeholders?: PlaceholderInfo[],
): Promise<Buffer> => {
const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf));
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
/*
Convert PlaceholderInfo[] to whiteout regions.
PlaceholderInfo uses top-left origin, but whiteoutRegions expects bottom-left.
*/
const regions = resolved.map((p) => {
const page = pages[p.page - 1];
const bottomLeftY = page.height - p.y - p.height;
return {
pageIndex: p.page - 1,
bbox: { x: p.x, y: bottomLeftY, width: p.width, height: p.height },
};
});
whiteoutRegions(pdfDoc, regions);
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};
/**
* Extract placeholders from a PDF and remove them from the document.
*
* Returns the cleaned PDF buffer and the extracted placeholders. If no
* placeholders are found the original buffer is returned as-is.
*/
export const extractPdfPlaceholders = async (
pdf: Buffer,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] };
}
const cleanedPdf = await removePlaceholdersFromPDF(pdf, placeholders);
return { cleanedPdf, placeholders };
};
/**
* Convert pre-extracted PlaceholderInfo[] to field creation inputs.
*
* Pure data transform — converts point-based coordinates to percentages and
* resolves recipient references via the provided callback. No DB calls.
*/
export const convertPlaceholdersToFieldInputs = (
placeholders: PlaceholderInfo[],
recipientResolver: (recipientPlaceholder: string, placeholder: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
return placeholders.map((p) => {
const xPercent = (p.x / p.pageWidth) * 100;
const yPercent = (p.y / p.pageHeight) * 100;
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 recipient = recipientResolver(p.recipient, p.placeholder);
return {
...p.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: p.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
+152
View File
@@ -0,0 +1,152 @@
import { FieldType } from '@prisma/client';
import type { Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
type RecipientPlaceholderInfo = {
email: string;
name: string;
recipientIndex: number;
};
/*
Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
This ensures we handle case variations and whitespace, and provides clear error messages.
*/
export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => {
const normalizedType = fieldTypeString.toUpperCase().trim();
return match(normalizedType)
.with('SIGNATURE', () => FieldType.SIGNATURE)
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
.with('INITIALS', () => FieldType.INITIALS)
.with('NAME', () => FieldType.NAME)
.with('EMAIL', () => FieldType.EMAIL)
.with('DATE', () => FieldType.DATE)
.with('TEXT', () => FieldType.TEXT)
.with('NUMBER', () => FieldType.NUMBER)
.with('RADIO', () => FieldType.RADIO)
.with('CHECKBOX', () => FieldType.CHECKBOX)
.with('DROPDOWN', () => FieldType.DROPDOWN)
.otherwise(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field type: ${fieldTypeString}`,
});
});
};
/*
Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
Converts string values to proper types (booleans, numbers).
*/
export const parseFieldMetaFromPlaceholder = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): Record<string, unknown> | undefined => {
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
return;
}
if (Object.keys(rawFieldMeta).length === 0) {
return;
}
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = {
type: fieldTypeString,
};
/*
rawFieldMeta is an object with string keys and string values.
It contains string values because the PDF parser returns the values as strings.
E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' }
*/
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = value === 'true';
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit'
) {
const numValue = Number(value);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} else {
parsedFieldMeta[property] = value;
}
}
return parsedFieldMeta;
};
const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
const indexMatch = placeholder.match(/^r(\d+)$/i);
if (!indexMatch) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
});
}
const recipientIndex = Number(indexMatch[1]);
return {
email: `recipient.${recipientIndex}@documenso.com`,
name: `Recipient ${recipientIndex}`,
recipientIndex,
};
};
/*
Finds a recipient based on a placeholder reference.
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
Otherwise, uses email-based matching from createdRecipients.
*/
export const findRecipientByPlaceholder = (
recipientPlaceholder: string,
placeholder: string,
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
): Pick<Recipient, 'id' | 'email'> => {
if (recipients && recipients.length > 0) {
/*
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
recipientIndex is 1-based, so we subtract 1 to get the array index.
*/
const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder);
const recipientArrayIndex = recipientIndex - 1;
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
});
}
return recipients[recipientArrayIndex];
}
/*
Use email-based matching for placeholder recipients.
*/
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
const recipient = createdRecipients.find((r) => r.email === email);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Could not find recipient ID for placeholder: ${placeholder}`,
});
}
return recipient;
};
@@ -28,5 +28,7 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
pdfDoc.flattenAnnotations();
}
return Buffer.from(await pdfDoc.save());
const normalizedPdfBytes = await pdfDoc.save();
return Buffer.from(normalizedPdfBytes);
};
+1 -2
View File
@@ -1,4 +1,3 @@
import { msg } from '@lingui/core/macro';
import { FieldType } from '@prisma/client';
import { z } from 'zod';
@@ -88,7 +87,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text'),
text: z.string().optional(),
characterLimit: z.coerce
.number({ invalid_type_error: msg`Value must be a number`.id })
.number({ invalid_type_error: 'Value must be a number' })
.min(0)
.optional(),
textAlign: ZFieldTextAlignSchema.optional(),