mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 06:47:10 +10:00
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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;
|
|
name: string;
|
|
recipientIndex: number;
|
|
};
|
|
|
|
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
|
|
atLeast: 'Select at least',
|
|
exactly: 'Select exactly',
|
|
atMost: 'Select at most',
|
|
};
|
|
|
|
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
|
|
const parts: string[] = [];
|
|
let currentPart = '';
|
|
|
|
for (let index = 0; index < value.length; index++) {
|
|
const char = value[index];
|
|
const nextChar = value[index + 1];
|
|
|
|
if (char === '\\' && nextChar) {
|
|
currentPart += char + nextChar;
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (char === delimiter) {
|
|
parts.push(currentPart);
|
|
currentPart = '';
|
|
continue;
|
|
}
|
|
|
|
currentPart += char;
|
|
}
|
|
|
|
parts.push(currentPart);
|
|
|
|
return parts;
|
|
};
|
|
|
|
const splitPlaceholderKeyValue = (value: string): [string, string] | null => {
|
|
const [key, ...valueParts] = splitPlaceholderToken(value, '=');
|
|
|
|
if (!key || valueParts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return [key.trim(), valueParts.join('=').trim()];
|
|
};
|
|
|
|
const unescapePlaceholderValue = (value: string): string => {
|
|
return value.replace(/\\([,=|\\])/g, '$1');
|
|
};
|
|
|
|
const normalizePlaceholderSelectionValue = (value: string): string => {
|
|
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
|
|
};
|
|
|
|
const parsePlaceholderOptions = (value: string): string[] => {
|
|
return splitPlaceholderToken(value, '|')
|
|
.map((option) => normalizePlaceholderSelectionValue(option))
|
|
.filter((option) => option.length > 0);
|
|
};
|
|
|
|
export const parsePlaceholderData = (value: string): string[] => {
|
|
return splitPlaceholderToken(value, ',').map((token) => token.trim());
|
|
};
|
|
|
|
export const parseRawFieldMetaFromPlaceholder = (
|
|
fieldMetaData: string[],
|
|
): Record<string, string> => {
|
|
const rawFieldMeta: Record<string, string> = {};
|
|
|
|
for (const fieldMeta of fieldMetaData) {
|
|
const keyValue = splitPlaceholderKeyValue(fieldMeta);
|
|
|
|
if (!keyValue) {
|
|
continue;
|
|
}
|
|
|
|
const [key, value] = keyValue;
|
|
|
|
rawFieldMeta[key] = value;
|
|
}
|
|
|
|
return rawFieldMeta;
|
|
};
|
|
|
|
/*
|
|
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}`,
|
|
});
|
|
});
|
|
};
|
|
|
|
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
|
|
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
|
|
|
|
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
|
|
};
|
|
|
|
const getCheckedFieldMetaValues = (rawFieldMeta: Record<string, string>) => {
|
|
const checkedValue = rawFieldMeta.checked;
|
|
|
|
return checkedValue ? parsePlaceholderOptions(checkedValue) : [];
|
|
};
|
|
|
|
const parseCheckboxValidationRule = (value: string): string => {
|
|
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
|
|
|
|
if (!validationRule) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: `Invalid checkbox placeholder validation rule: ${value}`,
|
|
});
|
|
}
|
|
|
|
return validationRule;
|
|
};
|
|
|
|
const parseSelectionFieldOptions = (
|
|
rawFieldMeta: Record<string, string>,
|
|
fieldType: FieldType,
|
|
): string[] | undefined => {
|
|
const rawOptions = rawFieldMeta.options;
|
|
|
|
if (rawOptions === undefined) {
|
|
return;
|
|
}
|
|
|
|
const parsedOptions = parsePlaceholderOptions(rawOptions);
|
|
|
|
if (parsedOptions.length === 0) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: `${fieldType} placeholder options must contain at least one value`,
|
|
});
|
|
}
|
|
|
|
return parsedOptions;
|
|
};
|
|
|
|
const applyRadioFieldOptions = (
|
|
parsedFieldMeta: Record<string, unknown>,
|
|
rawFieldMeta: Record<string, string>,
|
|
) => {
|
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
|
|
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
|
|
|
if (!options && defaultValue) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Radio placeholder default value requires options',
|
|
});
|
|
}
|
|
|
|
if (!options) {
|
|
return;
|
|
}
|
|
|
|
const selectedOptionIndex = defaultValue
|
|
? options.findIndex((option) => option === defaultValue)
|
|
: -1;
|
|
|
|
if (defaultValue && selectedOptionIndex === -1) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
|
|
});
|
|
}
|
|
|
|
parsedFieldMeta.values = options.map((option, index) => ({
|
|
id: index + 1,
|
|
checked: index === selectedOptionIndex,
|
|
value: option,
|
|
}));
|
|
};
|
|
|
|
const applyCheckboxFieldOptions = (
|
|
parsedFieldMeta: Record<string, unknown>,
|
|
rawFieldMeta: Record<string, string>,
|
|
) => {
|
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
|
|
const checkedValues = getCheckedFieldMetaValues(rawFieldMeta);
|
|
|
|
if (!options && checkedValues.length > 0) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Checkbox placeholder checked values require options',
|
|
});
|
|
}
|
|
|
|
if (!options) {
|
|
return;
|
|
}
|
|
|
|
const unmatchedCheckedValues = checkedValues.filter(
|
|
(checkedValue) => !options.includes(checkedValue),
|
|
);
|
|
|
|
if (unmatchedCheckedValues.length > 0) {
|
|
const unmatchedCheckedValue = unmatchedCheckedValues[0];
|
|
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: [
|
|
`Checkbox placeholder checked value "${unmatchedCheckedValue}"`,
|
|
'must match one of the options',
|
|
].join(' '),
|
|
});
|
|
}
|
|
|
|
parsedFieldMeta.values = options.map((option, index) => ({
|
|
id: index + 1,
|
|
checked: checkedValues.includes(option),
|
|
value: option,
|
|
}));
|
|
};
|
|
|
|
const applyDropdownFieldOptions = (
|
|
parsedFieldMeta: Record<string, unknown>,
|
|
rawFieldMeta: Record<string, string>,
|
|
) => {
|
|
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
|
|
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
|
|
|
|
if (!options && defaultValue) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Dropdown placeholder default value requires options',
|
|
});
|
|
}
|
|
|
|
if (!options) {
|
|
return;
|
|
}
|
|
|
|
if (defaultValue && !options.includes(defaultValue)) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
|
|
});
|
|
}
|
|
|
|
parsedFieldMeta.values = options.map((option) => ({
|
|
value: option,
|
|
}));
|
|
|
|
if (defaultValue) {
|
|
parsedFieldMeta.defaultValue = defaultValue;
|
|
}
|
|
};
|
|
|
|
const isSelectionFieldType = (fieldType: FieldType): boolean => {
|
|
return (
|
|
fieldType === FieldType.CHECKBOX ||
|
|
fieldType === FieldType.RADIO ||
|
|
fieldType === FieldType.DROPDOWN
|
|
);
|
|
};
|
|
|
|
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
|
|
if (property === 'options' || property === 'default' || property === 'selected') {
|
|
return true;
|
|
}
|
|
|
|
if (!isSelectionFieldType(fieldType)) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
property === 'label' ||
|
|
property === 'placeholder' ||
|
|
property === 'defaultValue' ||
|
|
(property === 'checked' && fieldType === FieldType.CHECKBOX)
|
|
);
|
|
};
|
|
|
|
/*
|
|
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, unknown> = {
|
|
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 (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
|
|
continue;
|
|
}
|
|
|
|
const unescapedValue = unescapePlaceholderValue(value);
|
|
|
|
if (property === 'readOnly' || property === 'required') {
|
|
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
|
|
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
|
|
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
|
|
} else if (
|
|
property === 'fontSize' ||
|
|
property === 'maxValue' ||
|
|
property === 'minValue' ||
|
|
property === 'characterLimit' ||
|
|
property === 'validationLength'
|
|
) {
|
|
const numValue = Number(unescapedValue);
|
|
|
|
if (!Number.isNaN(numValue)) {
|
|
parsedFieldMeta[property] = numValue;
|
|
}
|
|
} else {
|
|
parsedFieldMeta[property] = unescapedValue;
|
|
}
|
|
}
|
|
|
|
if (fieldType === FieldType.RADIO) {
|
|
applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta);
|
|
}
|
|
|
|
if (fieldType === FieldType.CHECKBOX) {
|
|
applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta);
|
|
}
|
|
|
|
if (fieldType === FieldType.DROPDOWN) {
|
|
applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta);
|
|
}
|
|
|
|
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;
|
|
};
|