mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 21:45:18 +10:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d44e1c59 | |||
| 9dc66afc7b | |||
| 7a13be6bf2 | |||
| 1032028395 | |||
| 2396d0d5d0 | |||
| dac262edc9 | |||
| 25eb4ffedf | |||
| ee0ea82635 | |||
| 5f7f6698fd | |||
| 402809c809 | |||
| 733e273c05 | |||
| 7c7933c2d4 | |||
| bb120d65dd | |||
| 9c14aa6297 | |||
| 6387e809a8 | |||
| 5b2b1591a4 | |||
| e5cb4c6bfd | |||
| 8185f916d3 | |||
| c308dbf257 |
@@ -0,0 +1,222 @@
|
||||
---
|
||||
date: 2026-05-07
|
||||
title: Pdf Placeholder Selection Fields
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
|
||||
|
||||
```text
|
||||
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
|
||||
```
|
||||
|
||||
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the current placeholder grammar unchanged.
|
||||
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
|
||||
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
|
||||
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
|
||||
- Use `options` as the only public list key in PDF placeholders.
|
||||
- Convert `options` into internal `fieldMeta.values` during parsing.
|
||||
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semicolon placeholder syntax.
|
||||
- No `values` alias in PDF placeholder syntax.
|
||||
- No database migration.
|
||||
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
|
||||
|
||||
## Placeholder Syntax
|
||||
|
||||
Use the existing comma-separated placeholder format:
|
||||
|
||||
```text
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
|
||||
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
|
||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
|
||||
|
||||
Parsing rules:
|
||||
|
||||
- Split top-level placeholder tokens on unescaped commas.
|
||||
- Split metadata tokens on the first unescaped equals sign.
|
||||
- Split `options` on unescaped pipes.
|
||||
- Trim option values and drop empty values.
|
||||
- Preserve option order.
|
||||
- Support escaped delimiters: `\,`, `\=`, and `\|`.
|
||||
- Treat field type values case-insensitively.
|
||||
|
||||
## Field Type Mapping
|
||||
|
||||
- `checkbox` maps to `FieldType.CHECKBOX`.
|
||||
- `radio` maps to `FieldType.RADIO`.
|
||||
- `dropdown` maps to `FieldType.DROPDOWN`.
|
||||
|
||||
## Metadata Mapping
|
||||
|
||||
### Checkbox
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
fieldMeta: {
|
||||
type: 'checkbox',
|
||||
validationRule: 'Select at least',
|
||||
validationLength: 1,
|
||||
values: [
|
||||
{ id: 1, value: 'Email', checked: true },
|
||||
{ id: 2, value: 'SMS', checked: false },
|
||||
{ id: 3, value: 'Phone', checked: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `checked`
|
||||
- `direction=vertical|horizontal`
|
||||
- `validationRule=atLeast|exactly|atMost`
|
||||
- `validationLength=1`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
|
||||
|
||||
Checkbox placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
### Radio
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, value: 'Card', checked: false },
|
||||
{ id: 2, value: 'Bank transfer', checked: true },
|
||||
{ id: 3, value: 'Check', checked: false },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `selected`, `default`, or `defaultValue`
|
||||
- `direction=vertical|horizontal`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
Radio placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
### Dropdown
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
Normalize to:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
|
||||
defaultValue: 'Legal',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Accepted keys:
|
||||
|
||||
- `options`
|
||||
- `selected`, `default`, or `defaultValue`
|
||||
- `required=true|false`
|
||||
- `readOnly=true|false`
|
||||
- `fontSize=12`
|
||||
|
||||
`defaultValue` should only be set if it matches one parsed option.
|
||||
|
||||
Dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
## Code Touchpoints
|
||||
|
||||
- `packages/lib/server-only/pdf/helpers.ts`
|
||||
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
|
||||
- Add delimiter-aware helpers for commas, equals signs, and pipes.
|
||||
- `packages/lib/server-only/pdf/auto-place-fields.ts`
|
||||
- Replace plain comma splitting with delimiter-aware splitting.
|
||||
- Preserve the existing positional structure: field type, recipient, metadata.
|
||||
- `packages/lib/types/field-meta.ts`
|
||||
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
|
||||
- `packages/ui/primitives/document-flow/field-content.tsx`
|
||||
- Display a radio fallback when a placeholder-created radio has no options.
|
||||
- Docs:
|
||||
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
|
||||
- `apps/docs/content/docs/developers/api/fields.mdx`
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `options=Yes|No|Maybe` becomes stable radio values.
|
||||
- `selected=No` marks only the matching radio option checked.
|
||||
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
|
||||
- Dropdown `options` and `defaultValue` normalize correctly.
|
||||
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
|
||||
|
||||
E2E/API tests:
|
||||
|
||||
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
|
||||
- Verify created fields have schema-compatible metadata and expected options/defaults.
|
||||
|
||||
Suggested verification:
|
||||
|
||||
```bash
|
||||
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
|
||||
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
|
||||
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
|
||||
npx tsc --noEmit -p packages/lib/tsconfig.json
|
||||
npx tsc --noEmit -p apps/remix/tsconfig.json
|
||||
```
|
||||
|
||||
Do not use `npm run build` for routine verification unless explicitly requested.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the existing placeholder format.
|
||||
- Use only `options` publicly.
|
||||
- Keep `values` as an internal metadata field only.
|
||||
- Use `|` as the option delimiter inside `options`.
|
||||
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
|
||||
| `maxValue` | Number | Maximum allowed value |
|
||||
| `numberFormat` | Format string | Number display format |
|
||||
|
||||
### Selection Field Options
|
||||
|
||||
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
|
||||
Separate choices with pipe (`|`) characters.
|
||||
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
|
||||
|
||||
| Option | Applies To | Values | Description |
|
||||
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
|
||||
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
|
||||
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
|
||||
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
|
||||
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
|
||||
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
|
||||
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
|
||||
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
|
||||
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
|
||||
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
|
||||
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
|
||||
|
||||
For checkbox validation, `validationLength` defines the option count:
|
||||
- `atLeast` means at least that many options must be selected
|
||||
- `exactly` means exactly that many options must be selected
|
||||
- `atMost` means at most that many options must be selected
|
||||
|
||||
If an option needs a literal delimiter, escape it with a backslash:
|
||||
|
||||
```
|
||||
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
|
||||
```
|
||||
|
||||
### Examples with Options
|
||||
|
||||
```
|
||||
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
|
||||
{{number, r1, minValue=0, maxValue=100, value=50}}
|
||||
{{name, r1, fontSize=14}}
|
||||
{{text, r2, readOnly=true, text=Contract #12345}}
|
||||
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
|
||||
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
|
||||
{{dropdown, r1, options=United States|Canada|United Kingdom}}
|
||||
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
mode: 'onBlur',
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
|
||||
export const DirectTemplateInvalidPageView = () => {
|
||||
return (
|
||||
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||
<div>
|
||||
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
|
||||
|
||||
<h1 className="mt-4 font-semibold text-3xl">
|
||||
<Trans>Invalid direct link template</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
This direct link template cannot be used because one or more signers do not have a signature field assigned.
|
||||
Please contact the sender to update the template.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Warns that a direct link template cannot be used because one or more signers
|
||||
* are missing a signature field.
|
||||
*/
|
||||
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
|
||||
className,
|
||||
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
|
||||
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
|
||||
|
||||
const signersMissingSignatureFields = useMemo(() => {
|
||||
if (!isTemplate || !envelope.directLink?.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
|
||||
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
|
||||
|
||||
if (signersMissingSignatureFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
|
||||
>
|
||||
<AlertTitle>
|
||||
<Trans>Invalid direct link template</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Recipients cannot use this direct link template because the following signers are missing a signature field
|
||||
</Trans>
|
||||
|
||||
<ul className="list-disc pl-5">
|
||||
{signersMissingSignatureFields.map((recipient, i) => (
|
||||
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
|
||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
|
||||
|
||||
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
|
||||
|
||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||
|
||||
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
|
||||
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<input {...getReplaceInputProps()} />
|
||||
|
||||
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
|
||||
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Plural } from '@lingui/react/macro';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
@@ -15,7 +14,6 @@ import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
|
||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
@@ -72,18 +70,8 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
};
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
isTemplateMissingSignatures: false,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
isTemplateMissingSignatures: false,
|
||||
envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
@@ -121,13 +108,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
};
|
||||
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { template, directTemplateRecipient } = data;
|
||||
|
||||
return (
|
||||
@@ -259,10 +235,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||
}
|
||||
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { envelope, recipient } = data.envelopeForSigning;
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
|
||||
@@ -32,10 +32,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
|
||||
return match(code)
|
||||
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||
title: msg`Missing signature fields`,
|
||||
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Something went wrong`,
|
||||
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
||||
@@ -81,10 +77,6 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
|
||||
title: msg`Error`,
|
||||
description: msg`The document was created but could not be sent to recipients.`,
|
||||
}))
|
||||
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||
title: msg`Missing signature fields`,
|
||||
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
|
||||
}))
|
||||
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
|
||||
|
||||
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
|
||||
|
||||
/**
|
||||
* Place a field on the PDF canvas in the envelope editor.
|
||||
*/
|
||||
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a V2 direct template and open it in the native template editor.
|
||||
*
|
||||
* Only the native template editor is covered here: direct links only exist
|
||||
* for templates and are not part of the embedded editor surfaces.
|
||||
*/
|
||||
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: `E2E Direct Template Validation ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
};
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
await expect(page.getByText('are missing a signature field')).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not show the warning when all signers have signature fields', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
|
||||
|
||||
// Wait for the editor to render before asserting the banner is absent.
|
||||
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('warning disappears after placing a signature field', async ({ page }) => {
|
||||
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||
|
||||
// Place a signature field for the direct recipient (auto-selected single recipient).
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
|
||||
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
|
||||
|
||||
// The banner clears once the field is autosaved and the envelope state updates.
|
||||
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
||||
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
||||
|
||||
const directSignatureField = directTemplate.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: 'Sign' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
|
||||
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||
|
||||
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
// Wait for the PDF and the Konva canvas overlay to be ready.
|
||||
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
|
||||
await signSignaturePad(page);
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
const x =
|
||||
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
|
||||
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
|
||||
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||
|
||||
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
const directSignatureField = template.fields[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
}
|
||||
|
||||
await signSignaturePad(page);
|
||||
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
},
|
||||
});
|
||||
|
||||
// The seeded direct template already includes a signature field for the direct recipient.
|
||||
const directSignatureField = template.fields[0];
|
||||
const directTemplateRecipient = template.recipients[0];
|
||||
|
||||
if (!directSignatureField) {
|
||||
throw new Error('Expected seeded direct template signature field to exist');
|
||||
if (!directTemplateRecipient) {
|
||||
throw new Error('Expected direct template recipient to exist');
|
||||
}
|
||||
|
||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
positionY: 10,
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
||||
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
||||
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V1 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow must not render.
|
||||
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'V2 invalid direct template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createDirectRecipientSignatureField: false,
|
||||
});
|
||||
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||
|
||||
// The signing flow (PDF canvas) must not render.
|
||||
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: options.envelopeId },
|
||||
});
|
||||
|
||||
return await prisma.field.create({
|
||||
data: {
|
||||
envelopeId: options.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: options.recipientId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// seedTemplate creates one SIGNER recipient and no fields.
|
||||
await seedTemplate({
|
||||
title: 'Template missing signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
// Enable distribution so the document is sent on creation.
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Missing signature fields');
|
||||
await expectToastTextToBeVisible(
|
||||
page,
|
||||
'The document could not be sent because some signers do not have a signature field',
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedTemplate({
|
||||
title: 'Template with signature fields',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await seedSignatureFieldForRecipient({
|
||||
envelopeId: template.id,
|
||||
recipientId: template.recipients[0].id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||
|
||||
await page.locator('#distributeDocument').click();
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const envelopeId = page.url().split('/').pop()?.split('?')[0];
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -36,13 +36,6 @@ export enum AppErrorCode {
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* A signer recipient does not have a signature field assigned. Thrown when
|
||||
* distributing an envelope or using a direct template where at least one
|
||||
* signer has no signature field.
|
||||
*/
|
||||
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
|
||||
|
||||
/**
|
||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||
* for the recovery taxonomy.
|
||||
@@ -91,7 +84,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -299,7 +291,6 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_CANCELLED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
|
||||
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
||||
.join(', ');
|
||||
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
envelopeId: envelope.id,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const { documentData } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
|
||||
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
envelopeId: envelope.id,
|
||||
fileName: data.file.name,
|
||||
});
|
||||
|
||||
// Upload the new PDF and get a new DocumentData record.
|
||||
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { match } from 'ts-pattern';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
@@ -126,17 +125,6 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||
envelope.recipients,
|
||||
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
|
||||
);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
message: 'One or more signers on this direct template are missing a signature field',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { PDF, rgb } from '@libpdf/core';
|
||||
import type { FieldType, Recipient } from '@prisma/client';
|
||||
|
||||
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
|
||||
import {
|
||||
parseFieldMetaFromPlaceholder,
|
||||
parseFieldTypeFromPlaceholder,
|
||||
parsePlaceholderData,
|
||||
parseRawFieldMetaFromPlaceholder,
|
||||
} from './helpers';
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
|
||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||
type ExtractPlaceholdersLogContext = {
|
||||
envelopeId?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (
|
||||
pdf: Buffer,
|
||||
logContext?: ExtractPlaceholdersLogContext,
|
||||
): Promise<PlaceholderInfo[]> => {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
|
||||
const placeholders: PlaceholderInfo[] = [];
|
||||
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
|
||||
const placeholderData = parsePlaceholderData(innerMatch[1]);
|
||||
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
|
||||
|
||||
let fieldType: FieldType;
|
||||
@@ -109,14 +124,51 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
|
||||
|
||||
const recipient = recipientOrMeta;
|
||||
|
||||
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
|
||||
/*
|
||||
Parse and validate the field metadata. A malformed selection placeholder
|
||||
(e.g. an unknown validation rule or a default value that doesn't match an
|
||||
option) is skipped like an invalid field type rather than aborting the whole
|
||||
upload, which may contain other valid placeholders and files.
|
||||
*/
|
||||
let fieldAndMeta: TFieldAndMeta;
|
||||
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
try {
|
||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||
|
||||
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
|
||||
type: fieldType,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
});
|
||||
|
||||
/*
|
||||
Surface schema failures as INVALID_BODY (400) instead of letting the raw
|
||||
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
|
||||
*/
|
||||
if (!parsedFieldAndMeta.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
fieldAndMeta = parsedFieldAndMeta.data;
|
||||
} catch (error) {
|
||||
const appError = AppError.parseError(error);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
envelopeId: logContext?.envelopeId,
|
||||
fileName: logContext?.fileName,
|
||||
placeholder,
|
||||
page: page.index + 1,
|
||||
code: appError.code,
|
||||
message: appError.message,
|
||||
},
|
||||
'Skipping placeholder with invalid field metadata',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
LibPDF returns bbox in points with bottom-left origin.
|
||||
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
|
||||
*/
|
||||
export const extractPdfPlaceholders = async (
|
||||
pdf: Buffer,
|
||||
logContext?: ExtractPlaceholdersLogContext,
|
||||
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
|
||||
|
||||
if (placeholders.length === 0) {
|
||||
return { cleanedPdf: pdf, placeholders: [] };
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
parseFieldMetaFromPlaceholder,
|
||||
parseFieldTypeFromPlaceholder,
|
||||
parsePlaceholderData,
|
||||
parseRawFieldMetaFromPlaceholder,
|
||||
} from './helpers';
|
||||
|
||||
const expectInvalidBody = (fn: () => unknown) => {
|
||||
try {
|
||||
fn();
|
||||
expect.unreachable('Expected an AppError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseFieldTypeFromPlaceholder function', () => {
|
||||
it('maps known field type strings to the FieldType enum', () => {
|
||||
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
|
||||
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
|
||||
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
|
||||
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims surrounding whitespace', () => {
|
||||
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
|
||||
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
|
||||
});
|
||||
|
||||
it('throws INVALID_BODY for an unknown field type', () => {
|
||||
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlaceholderData function', () => {
|
||||
it('splits top-level parts on commas and trims each token', () => {
|
||||
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
|
||||
});
|
||||
|
||||
it('does not split on escaped commas', () => {
|
||||
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
|
||||
'dropdown',
|
||||
'r1',
|
||||
'options=Legal\\, Compliance|Sales',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRawFieldMetaFromPlaceholder function', () => {
|
||||
it('splits each token into a key/value entry', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
|
||||
required: 'true',
|
||||
fontSize: '12',
|
||||
});
|
||||
});
|
||||
|
||||
it('only splits on the first unescaped equals sign', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
|
||||
});
|
||||
|
||||
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
|
||||
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
|
||||
fontSize: '14',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFieldMetaFromPlaceholder function', () => {
|
||||
describe('non-field-meta cases', () => {
|
||||
it('returns undefined for signature and free signature fields', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
|
||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when there is no metadata', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic metadata', () => {
|
||||
it('coerces required/readOnly to booleans (case-insensitive)', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
required: true,
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces numeric properties to numbers', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
fontSize: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops numeric properties that are not a number', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
|
||||
|
||||
expect(parsed).toEqual({ type: 'text' });
|
||||
expect(parsed).not.toHaveProperty('fontSize');
|
||||
});
|
||||
|
||||
it('keeps label/placeholder for non-selection fields', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
|
||||
type: 'text',
|
||||
label: 'Company Name',
|
||||
placeholder: 'Acme',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('radio fields', () => {
|
||||
it('builds stable values from options', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Yes' },
|
||||
{ id: 2, checked: false, value: 'No' },
|
||||
{ id: 3, checked: false, value: 'Maybe' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the selected option as checked', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Yes' },
|
||||
{ id: 2, checked: true, value: 'No' },
|
||||
{ id: 3, checked: false, value: 'Maybe' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when a default value is provided without options', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
|
||||
});
|
||||
|
||||
it('throws when the default value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
|
||||
});
|
||||
|
||||
it('throws when options is empty', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkbox fields', () => {
|
||||
it('builds values with checked state, validation rule alias and length', () => {
|
||||
const parsed = parseFieldMetaFromPlaceholder(
|
||||
{
|
||||
options: 'Email|SMS|Phone',
|
||||
checked: 'Email|Phone',
|
||||
validationRule: 'atLeast',
|
||||
validationLength: '1',
|
||||
},
|
||||
FieldType.CHECKBOX,
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'checkbox',
|
||||
validationRule: 'Select at least',
|
||||
validationLength: 1,
|
||||
values: [
|
||||
{ id: 1, checked: true, value: 'Email' },
|
||||
{ id: 2, checked: false, value: 'SMS' },
|
||||
{ id: 3, checked: true, value: 'Phone' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an unknown validation rule', () => {
|
||||
expectInvalidBody(() =>
|
||||
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when checked values are provided without options', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
|
||||
});
|
||||
|
||||
it('throws when a checked value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropdown fields', () => {
|
||||
it('builds values and sets a matching default value', () => {
|
||||
expect(
|
||||
parseFieldMetaFromPlaceholder(
|
||||
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
|
||||
FieldType.DROPDOWN,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
|
||||
defaultValue: 'Canada',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when the default value does not match an option', () => {
|
||||
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection field options parsing', () => {
|
||||
it('trims option values and drops empty entries', () => {
|
||||
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'A' }, { value: 'B' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses escaped delimiters through the full placeholder pipeline', () => {
|
||||
const [, , ...fieldMetaData] = parsePlaceholderData(
|
||||
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
|
||||
);
|
||||
|
||||
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
|
||||
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: 'dropdown',
|
||||
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
|
||||
recipientIndex: number;
|
||||
};
|
||||
|
||||
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
|
||||
atLeast: 'Select at least',
|
||||
exactly: 'Select exactly',
|
||||
atMost: 'Select at most',
|
||||
};
|
||||
|
||||
/*
|
||||
Split a string on a delimiter, treating `\` as an escape for the next character.
|
||||
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
|
||||
|
||||
With delimiter ',' (top-level placeholder parts):
|
||||
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
|
||||
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
|
||||
|
||||
With delimiter '=' (split one field metadata token into key + value):
|
||||
'options=Card/Check|Bank Transfer'
|
||||
-> ['options', 'Card/Check|Bank Transfer']
|
||||
|
||||
With delimiter '|' (split option list inside 'options='):
|
||||
'Card/Check|Bank Transfer'
|
||||
-> ['Card/Check', 'Bank Transfer']
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/*
|
||||
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
|
||||
|
||||
E.g.
|
||||
'Legal\, Compliance' -> 'Legal, Compliance'
|
||||
'Card\|Check' -> 'Card|Check'
|
||||
'A\=B' -> 'A=B'
|
||||
'C\D' -> 'C\D'
|
||||
*/
|
||||
const unescapePlaceholderValue = (value: string): string => {
|
||||
return value.replace(/\\([,=|\\])/g, '$1');
|
||||
};
|
||||
|
||||
/*
|
||||
Cleans up a selection option/default after splitting:
|
||||
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
|
||||
|
||||
E.g.
|
||||
' Legal\, Compliance ' -> 'Legal, Compliance'
|
||||
*/
|
||||
const normalizePlaceholderSelectionValue = (value: string): string => {
|
||||
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
/*
|
||||
Split an options string into individual choices.
|
||||
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
|
||||
|
||||
E.g.
|
||||
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
|
||||
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
|
||||
*/
|
||||
const parsePlaceholderOptions = (value: string): string[] => {
|
||||
return splitPlaceholderToken(value, '|')
|
||||
.map((option) => normalizePlaceholderSelectionValue(option))
|
||||
.filter((option) => option.length > 0);
|
||||
};
|
||||
|
||||
/*
|
||||
Split a placeholder string into top-level parts (field type, recipient, metadata).
|
||||
Splits on unescaped commas, then trims whitespace.
|
||||
|
||||
E.g.
|
||||
'SIGNATURE, r1, required=true'
|
||||
-> ['SIGNATURE', 'r1', 'required=true']
|
||||
*/
|
||||
export const parsePlaceholderData = (value: string): string[] => {
|
||||
return splitPlaceholderToken(value, ',').map((token) => token.trim());
|
||||
};
|
||||
|
||||
/*
|
||||
Transforms the field metadata string array into a record of key/value pairs.
|
||||
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
|
||||
|
||||
E.g.
|
||||
['required=true', 'fontSize=12', 'label=a=b']
|
||||
-> { required: 'true', fontSize: '12', label: 'a=b' }
|
||||
*/
|
||||
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
|
||||
const rawFieldMeta: Record<string, string> = {};
|
||||
|
||||
for (const fieldMeta of fieldMetaData) {
|
||||
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
|
||||
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
|
||||
|
||||
if (!rawKey || valueParts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = rawKey.trim();
|
||||
const value = valueParts.join('=').trim();
|
||||
|
||||
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.
|
||||
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
|
||||
});
|
||||
};
|
||||
|
||||
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
|
||||
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
|
||||
|
||||
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
|
||||
};
|
||||
|
||||
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 = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Generic field metadata properties are simple properties consisting of a key and a value.
|
||||
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
|
||||
They don't require special handling.
|
||||
|
||||
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
|
||||
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
|
||||
They require special handling.
|
||||
*/
|
||||
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
|
||||
if (property === 'options' || property === 'default' || property === 'selected') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isSelectionField =
|
||||
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
|
||||
|
||||
if (!isSelectionField) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
property === 'label' ||
|
||||
property === 'placeholder' ||
|
||||
property === 'defaultValue' ||
|
||||
(fieldType === FieldType.CHECKBOX && property === 'checked')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/*
|
||||
Transform raw field metadata from placeholder format to schema format.
|
||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
|
||||
|
||||
const fieldTypeString = String(fieldType).toLowerCase();
|
||||
|
||||
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
||||
const parsedFieldMeta: Record<string, unknown> = {
|
||||
type: fieldTypeString,
|
||||
};
|
||||
|
||||
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
|
||||
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] = value === 'true';
|
||||
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 === 'characterLimit' ||
|
||||
property === 'validationLength'
|
||||
) {
|
||||
const numValue = Number(value);
|
||||
const numValue = Number(unescapedValue);
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
parsedFieldMeta[property] = numValue;
|
||||
}
|
||||
} else {
|
||||
parsedFieldMeta[property] = value;
|
||||
parsedFieldMeta[property] = unescapedValue;
|
||||
}
|
||||
}
|
||||
|
||||
match(fieldType)
|
||||
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
|
||||
.otherwise(() => undefined);
|
||||
|
||||
return parsedFieldMeta;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
extractDocumentAuthMethods,
|
||||
} from '../../utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||
import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
@@ -173,17 +172,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||
recipients,
|
||||
recipients.flatMap((recipient) => recipient.fields),
|
||||
);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||
message: 'One or more signers on this direct template are missing a signature field',
|
||||
});
|
||||
}
|
||||
|
||||
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||
}
|
||||
|
||||
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
|
||||
const { id, recipients, envelopeItems } = createdEnvelope;
|
||||
|
||||
if (isDirectTemplate) {
|
||||
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
||||
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
if (!directTemplateRecpient) {
|
||||
throw new Error('Need to create a direct template recipient');
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
|
||||
envelopeId: id,
|
||||
enabled: true,
|
||||
token: directTemplateToken ?? Math.random().toString(),
|
||||
directTemplateRecipientId: directTemplateRecipient.id,
|
||||
directTemplateRecipientId: directTemplateRecpient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||
} from '@documenso/lib/constants/direct-templates';
|
||||
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
DocumentDataType,
|
||||
DocumentSource,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
@@ -31,11 +29,6 @@ type SeedTemplateOptions = {
|
||||
teamId: number;
|
||||
internalVersion?: 1 | 2;
|
||||
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
|
||||
/**
|
||||
* Only used by seedDirectTemplate. Creates a signature field for the direct
|
||||
* recipient so the seeded direct template is valid. Defaults to true.
|
||||
*/
|
||||
createDirectRecipientSignatureField?: boolean;
|
||||
};
|
||||
|
||||
type CreateTemplateOptions = {
|
||||
@@ -205,11 +198,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplateRecipient = template.recipients.find(
|
||||
const directTemplateRecpient = template.recipients.find(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
);
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
if (!directTemplateRecpient) {
|
||||
throw new Error('Need to create a direct template recipient');
|
||||
}
|
||||
|
||||
@@ -218,35 +211,10 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
||||
envelopeId: template.id,
|
||||
enabled: true,
|
||||
token: Math.random().toString(),
|
||||
directTemplateRecipientId: directTemplateRecipient.id,
|
||||
directTemplateRecipientId: directTemplateRecpient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { createDirectRecipientSignatureField = true } = options;
|
||||
|
||||
if (createDirectRecipientSignatureField) {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: template.id },
|
||||
});
|
||||
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recipientId: directTemplateRecipient.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: template.id,
|
||||
|
||||
@@ -124,7 +124,9 @@ export const createEnvelopeRouteCaller = async ({
|
||||
});
|
||||
|
||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const { documentData } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
|
||||
Reference in New Issue
Block a user