mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45117cef14 | ||
|
|
d42254ff52 | ||
|
|
914e325486 | ||
|
|
05f646b326 | ||
|
|
0099dd672a | ||
|
|
871c2a6f0e | ||
|
|
9bab1cddb3 | ||
|
|
3e0c1c444a | ||
|
|
6a8fe6f1ad | ||
|
|
779de01fe8 | ||
|
|
283c6d274b | ||
|
|
2662329455 | ||
|
|
26f8a56248 | ||
|
|
fc4de113de | ||
|
|
5e11db2444 | ||
|
|
88e836ddbc | ||
|
|
874243700f | ||
|
|
824117d47e | ||
|
|
d33714a4e5 | ||
|
|
b620a8c6d7 | ||
|
|
b8a11df768 |
@@ -0,0 +1,430 @@
|
||||
---
|
||||
date: 2026-05-21
|
||||
title: Acroform Field Detection And Reuse
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
|
||||
|
||||
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
|
||||
|
||||
## Goal
|
||||
|
||||
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
|
||||
|
||||
## Background
|
||||
|
||||
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
|
||||
|
||||
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
|
||||
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf` → `normalizePdf({ flattenForm: type !== 'TEMPLATE' })` → `extractPdfPlaceholders(normalized)` → `putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
|
||||
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
|
||||
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
|
||||
|
||||
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
|
||||
|
||||
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
|
||||
- `PDFForm.getFields(): FormField[]`.
|
||||
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
|
||||
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
|
||||
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
|
||||
|
||||
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
|
||||
|
||||
## Field Mapping
|
||||
|
||||
### Type resolution
|
||||
|
||||
| AcroForm type | Documenso field | Rule |
|
||||
| --- | --- | --- |
|
||||
| `signature` (unsigned) | `SIGNATURE` | Import. |
|
||||
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
|
||||
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
|
||||
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
|
||||
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
|
||||
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
|
||||
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
|
||||
|
||||
### Text-field heuristic (resolved in order — first match wins)
|
||||
|
||||
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
|
||||
|
||||
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA` → `/F` → `JS` containing `AFDate_FormatEx` or `AFDate_Format`).
|
||||
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
|
||||
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
|
||||
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
|
||||
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
|
||||
6. **NAME** if name/alternateName matches `/name/i`.
|
||||
7. **INITIALS** if name/alternateName matches `/initial/i`.
|
||||
8. Else **TEXT**.
|
||||
|
||||
All regexes are case-insensitive and run against `partialName` then `alternateName`.
|
||||
|
||||
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
|
||||
|
||||
### Metadata mapping
|
||||
|
||||
For every imported field:
|
||||
|
||||
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
|
||||
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
|
||||
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
|
||||
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
|
||||
|
||||
### CHECKBOX required semantics
|
||||
|
||||
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
|
||||
|
||||
```ts
|
||||
if (field.isRequired()) {
|
||||
fieldMeta.required = true;
|
||||
fieldMeta.validationRule = 'at-least';
|
||||
fieldMeta.validationLength = 1;
|
||||
}
|
||||
```
|
||||
|
||||
This approximates "must be checked to submit" for a single-widget checkbox field.
|
||||
|
||||
### Default values (only when `formValues` was NOT provided on this upload)
|
||||
|
||||
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
|
||||
|
||||
| Source | Target |
|
||||
| --- | --- |
|
||||
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
|
||||
| `DropdownField` current selection | `fieldMeta.defaultValue` |
|
||||
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
|
||||
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
|
||||
|
||||
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
|
||||
|
||||
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
|
||||
|
||||
## Pre-extraction Guards
|
||||
|
||||
Run in this order before any AcroForm work:
|
||||
|
||||
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
|
||||
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
|
||||
3. **`getForm()` is null** → return empty results silently.
|
||||
4. **Top-level try/catch** around steps 1–6: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
|
||||
|
||||
## Coordinate Handling
|
||||
|
||||
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
|
||||
|
||||
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
|
||||
2. For each widget:
|
||||
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
|
||||
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
|
||||
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
|
||||
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
|
||||
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
|
||||
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
|
||||
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
|
||||
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
|
||||
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
|
||||
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
|
||||
7. Convert to percentages against the rendered page dimensions from step 5.
|
||||
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
|
||||
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
|
||||
|
||||
## Ordering
|
||||
|
||||
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
|
||||
|
||||
```ts
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdFields.map((f) => createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
|
||||
})),
|
||||
});
|
||||
```
|
||||
|
||||
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
|
||||
|
||||
## Schema Extension
|
||||
|
||||
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
|
||||
|
||||
```ts
|
||||
export const ZBaseFieldMeta = z.object({
|
||||
// existing...
|
||||
source: z.enum(['acroform']).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Add the AcroForm extractor
|
||||
|
||||
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
|
||||
|
||||
```ts
|
||||
export type AcroFormFieldImportInfo = {
|
||||
source: 'acroform';
|
||||
fieldName: string;
|
||||
widgetIndex: number;
|
||||
fieldAndMeta: TFieldAndMeta;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type AcroFormUnsupportedFieldInfo = {
|
||||
fieldName: string;
|
||||
acroFormType: string;
|
||||
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
|
||||
};
|
||||
|
||||
export type AcroFormExtractionResult = {
|
||||
fields: AcroFormFieldImportInfo[];
|
||||
unsupported: AcroFormUnsupportedFieldInfo[];
|
||||
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
|
||||
hasSignedSignature: boolean;
|
||||
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
|
||||
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
|
||||
};
|
||||
|
||||
export const extractAcroFormFieldsFromPDF = async (
|
||||
pdf: Buffer,
|
||||
): Promise<AcroFormExtractionResult>;
|
||||
|
||||
export const convertAcroFormFieldsToFieldInputs = (
|
||||
fields: AcroFormFieldImportInfo[],
|
||||
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
|
||||
envelopeItemId?: string,
|
||||
): FieldToCreate[];
|
||||
```
|
||||
|
||||
`extractAcroFormFieldsFromPDF`:
|
||||
|
||||
- Wraps everything in try/catch (top-level guard).
|
||||
- Loads via `PDF.load(new Uint8Array(pdf))`.
|
||||
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
|
||||
- Builds the page-ref → index + rotation lookup once.
|
||||
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
|
||||
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
|
||||
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
|
||||
|
||||
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
|
||||
|
||||
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
|
||||
|
||||
### 2. Extract AcroForm fields before flattening
|
||||
|
||||
In both upload entry points the order becomes:
|
||||
|
||||
```
|
||||
convertToPdf (router only)
|
||||
→ insertFormValuesInPdf if formValues
|
||||
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
|
||||
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
|
||||
→ normalizePdf({ flattenForm: shouldFlatten })
|
||||
→ extractPdfPlaceholders(normalized)
|
||||
→ putPdfFileServerSide(cleanedPdf)
|
||||
→ forward { placeholders, acroFormFields, formValuesProvided }
|
||||
```
|
||||
|
||||
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
|
||||
|
||||
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
|
||||
|
||||
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
|
||||
|
||||
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
|
||||
|
||||
### 3. Thread `acroFormFields` through `createEnvelope`
|
||||
|
||||
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
|
||||
|
||||
```ts
|
||||
envelopeItems: {
|
||||
title?: string;
|
||||
documentDataId: string;
|
||||
order?: number;
|
||||
placeholders?: PlaceholderInfo[];
|
||||
acroFormFields?: AcroFormFieldImportInfo[]; // new
|
||||
formValuesProvided?: boolean; // new — already-applied prefill
|
||||
}[];
|
||||
```
|
||||
|
||||
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
|
||||
|
||||
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
|
||||
- Recipient resolution:
|
||||
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
|
||||
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
|
||||
- All imported fields → that one recipient. User reassigns in editor.
|
||||
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
|
||||
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
|
||||
|
||||
### 4. Mirror in `UNSAFE_createEnvelopeItems`
|
||||
|
||||
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
|
||||
|
||||
### 5. Log unsupported fields, never block upload
|
||||
|
||||
In both entry points, after extraction:
|
||||
|
||||
```ts
|
||||
if (extraction.unsupported.length > 0) {
|
||||
logger.info({
|
||||
event: 'acroform-import.unsupported',
|
||||
envelopeItemTitle,
|
||||
count: extraction.unsupported.length,
|
||||
byReason: groupBy(extraction.unsupported, u => u.reason),
|
||||
});
|
||||
}
|
||||
if (extraction.skipReason) {
|
||||
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
|
||||
}
|
||||
```
|
||||
|
||||
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
|
||||
|
||||
### 6. Schema extension
|
||||
|
||||
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
|
||||
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
|
||||
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
|
||||
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
|
||||
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
|
||||
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
|
||||
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
|
||||
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
|
||||
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
|
||||
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
|
||||
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
|
||||
|
||||
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
|
||||
|
||||
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
|
||||
|
||||
Type resolution:
|
||||
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
|
||||
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
|
||||
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
|
||||
|
||||
Metadata:
|
||||
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
|
||||
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
|
||||
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
|
||||
- Every imported field has `fieldMeta.source = 'acroform'`.
|
||||
|
||||
Default values:
|
||||
- TextField with non-empty value AND `formValuesProvided = false` → `fieldMeta.text` set.
|
||||
- TextField with non-empty value AND `formValuesProvided = true` → `fieldMeta.text` NOT set.
|
||||
- DropdownField selection → `fieldMeta.defaultValue`.
|
||||
- CheckboxField checked → `values[0].checked = true`.
|
||||
- RadioField selected → matching `values[i].checked = true`.
|
||||
|
||||
Geometry:
|
||||
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
|
||||
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
|
||||
- 180° and 270° rotated pages: same.
|
||||
- Hidden widgets (annotation flags hidden bit) → skipped.
|
||||
- Widgets with zero/negative dimensions → skipped.
|
||||
- Widgets with `pageRef` not in `pdfDoc.getPages()` → `unsupported` with `reason: 'no-page-match'`.
|
||||
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
|
||||
- Widget rect partially off-page → clamped, imported.
|
||||
|
||||
Ordering:
|
||||
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
|
||||
|
||||
Skips and unsupported:
|
||||
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
|
||||
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
|
||||
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
|
||||
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
|
||||
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
|
||||
|
||||
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
|
||||
|
||||
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
|
||||
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
|
||||
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
|
||||
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
|
||||
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
|
||||
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
|
||||
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
|
||||
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
|
||||
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
|
||||
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
|
||||
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
|
||||
|
||||
### Regression
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -p apps/remix/tsconfig.json
|
||||
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
|
||||
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
|
||||
```
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
|
||||
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
|
||||
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
|
||||
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
|
||||
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
|
||||
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
|
||||
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
|
||||
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
|
||||
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
|
||||
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
|
||||
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
|
||||
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
|
||||
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
|
||||
- A signed-AcroForm-signature → completed Documenso signature mapping.
|
||||
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
|
||||
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
|
||||
- Listbox support.
|
||||
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
|
||||
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
|
||||
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
|
||||
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
|
||||
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
|
||||
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
|
||||
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
|
||||
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-justification
|
||||
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: decision-making
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `swift-emerald-river`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
|
||||
Multi-line
|
||||
justification content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `swift-emerald-river-decision-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Decision Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-plan
|
||||
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: planning
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `happy-blue-moon`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
|
||||
Multi-line
|
||||
plan content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `happy-blue-moon-feature-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Feature Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: create-scratch
|
||||
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: agents
|
||||
workflow: exploration
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
|
||||
|
||||
- A unique three-word identifier (e.g., `calm-teal-cloud`)
|
||||
- Frontmatter with the current date and formatted title
|
||||
- Content you provide
|
||||
|
||||
## How to use
|
||||
|
||||
Run the script with a slug and content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
|
||||
```
|
||||
|
||||
Or use heredoc for multi-line content:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
|
||||
Multi-line
|
||||
scratch content
|
||||
goes here
|
||||
HEREDOC
|
||||
```
|
||||
|
||||
## File format
|
||||
|
||||
Files are created as: `{three-word-id}-{slug}.md`
|
||||
|
||||
Example: `calm-teal-cloud-note-name.md`
|
||||
|
||||
The file includes frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-01-13
|
||||
title: Note Name
|
||||
---
|
||||
|
||||
Your content here
|
||||
```
|
||||
|
||||
## When to use me
|
||||
|
||||
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
build_app:
|
||||
name: Build App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,6 +33,7 @@ jobs:
|
||||
build_docker:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
label-when-assigned:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Label issue
|
||||
uses: actions/github-script@v6
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
label_issues:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
|
||||
@@ -13,6 +13,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
with:
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
build_and_publish_platform_containers:
|
||||
name: Build and publish platform containers
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
create_and_publish_manifest:
|
||||
name: Create and publish manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: build_and_publish_platform_containers
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
validate-pr:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
id: lint_pr_title
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
pull_translations:
|
||||
name: Force pull translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
pull_translations:
|
||||
name: Pull translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
extract_translations:
|
||||
name: Extract and upload translations
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
# min-release-age = 7
|
||||
min-release-age = 7
|
||||
|
||||
@@ -29,14 +29,25 @@ Each document contains one or more PDF files, a list of recipients, and the fiel
|
||||
A document object contains the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------------- | -------------- | -------------------------------------------------------------- |
|
||||
| ------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | string | Unique identifier (e.g., `envelope_abc123`) |
|
||||
| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) |
|
||||
| `internalVersion` | number | Internal envelope schema version |
|
||||
| `type` | string | `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` |
|
||||
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` |
|
||||
| `title` | string | Document title |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` |
|
||||
| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` |
|
||||
| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` |
|
||||
| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) |
|
||||
| `externalId` | string \| null | Your custom identifier for the document |
|
||||
| `userId` | number | ID of the user who owns the document |
|
||||
| `teamId` | number | ID of the team the document belongs to |
|
||||
| `folderId` | string \| null | ID of the folder containing the document |
|
||||
| `templateId` | number \| null | Legacy ID of the template this document was created from |
|
||||
| `authOptions` | object \| null | Access and action authentication requirements |
|
||||
| `formValues` | object \| null | Pre-filled form values |
|
||||
| `publicTitle` | string | Public title shown on profile and direct-link pages |
|
||||
| `publicDescription` | string | Public description shown on profile and direct-link pages |
|
||||
| `createdAt` | string | ISO 8601 timestamp |
|
||||
| `updatedAt` | string | ISO 8601 timestamp |
|
||||
| `completedAt` | string \| null | Timestamp when all recipients completed signing |
|
||||
@@ -44,19 +55,35 @@ A document object contains the following properties:
|
||||
| `recipients` | array | List of recipients and their signing status |
|
||||
| `fields` | array | Signature and form fields on the document |
|
||||
| `envelopeItems` | array | PDF files attached to the document |
|
||||
| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) |
|
||||
| `team` | object | Owning team (`id`, `url`) |
|
||||
| `user` | object | Document owner (`id`, `name`, `email`) |
|
||||
| `documentMeta` | object | Email settings, redirect URL, signing options |
|
||||
|
||||
Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them.
|
||||
|
||||
### Example Document Object
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "envelope_abc123xyz",
|
||||
"secondaryId": "document_123",
|
||||
"internalVersion": 2,
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"source": "API",
|
||||
"source": "DOCUMENT",
|
||||
"visibility": "EVERYONE",
|
||||
"templateType": "PRIVATE",
|
||||
"title": "Service Agreement",
|
||||
"externalId": "contract-2025-001",
|
||||
"userId": 1,
|
||||
"teamId": 1,
|
||||
"folderId": null,
|
||||
"templateId": null,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"publicTitle": "",
|
||||
"publicDescription": "",
|
||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2025-01-15T10:35:00.000Z",
|
||||
"completedAt": null,
|
||||
@@ -73,23 +100,41 @@ A document object contains the following properties:
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"id": "field_123",
|
||||
"id": 123,
|
||||
"secondaryId": "field_abc123",
|
||||
"type": "SIGNATURE",
|
||||
"recipientId": 1,
|
||||
"envelopeId": "envelope_abc123xyz",
|
||||
"envelopeItemId": "envelope_item_xyz",
|
||||
"page": 1,
|
||||
"positionX": 10,
|
||||
"positionY": 80,
|
||||
"width": 30,
|
||||
"height": 5,
|
||||
"recipientId": 1
|
||||
"positionX": "10",
|
||||
"positionY": "80",
|
||||
"width": "30",
|
||||
"height": "5",
|
||||
"customText": "",
|
||||
"inserted": false,
|
||||
"fieldMeta": null
|
||||
}
|
||||
],
|
||||
"envelopeItems": [
|
||||
{
|
||||
"id": "envelope_item_xyz",
|
||||
"envelopeId": "envelope_abc123xyz",
|
||||
"documentDataId": "doc_data_abc123",
|
||||
"title": "contract.pdf",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"directLink": null,
|
||||
"team": {
|
||||
"id": 1,
|
||||
"url": "your-team"
|
||||
},
|
||||
"user": {
|
||||
"id": 1,
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com"
|
||||
},
|
||||
"documentMeta": {
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hi, please review and sign this agreement.",
|
||||
@@ -99,6 +144,8 @@ A document object contains the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
Field position and size values are stored as decimals and serialized as strings in API responses.
|
||||
|
||||
## List Documents
|
||||
|
||||
Retrieve a paginated list of documents.
|
||||
@@ -114,7 +161,7 @@ GET /envelope
|
||||
| `page` | integer | Page number (default: 1) |
|
||||
| `perPage` | integer | Results per page (default: 10, max: 100) |
|
||||
| `type` | string | Filter by `DOCUMENT` or `TEMPLATE` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` |
|
||||
| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
|
||||
| `source` | string | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder ID |
|
||||
| `orderByColumn` | string | Sort field (only `createdAt` supported) |
|
||||
@@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
console.log(`Found ${pagination.totalItems} documents`);
|
||||
const { data, count } = await response.json();
|
||||
console.log(`Found ${count} documents`);
|
||||
|
||||
// Filter by status
|
||||
const pendingResponse = await fetch(
|
||||
@@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json();
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"count": 42,
|
||||
"currentPage": 1,
|
||||
"perPage": 10,
|
||||
"totalPages": 5,
|
||||
"totalItems": 42
|
||||
}
|
||||
"totalPages": 5
|
||||
}
|
||||
```
|
||||
|
||||
@@ -628,6 +673,72 @@ The response includes signing URLs for each recipient:
|
||||
|
||||
---
|
||||
|
||||
## Cancel Document
|
||||
|
||||
Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`.
|
||||
|
||||
```
|
||||
POST /envelope/cancel
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------------------------------- |
|
||||
| `envelopeId` | string | Yes | Document ID |
|
||||
| `reason` | string | No | Reason for cancelling the document |
|
||||
|
||||
### Code Examples
|
||||
|
||||
<Tabs items={['curl', 'TypeScript']}>
|
||||
<Tab value="curl">
|
||||
```bash
|
||||
curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeId": "envelope_abc123",
|
||||
"reason": "The agreement is no longer needed."
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'api_xxxxxxxxxxxxxxxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeId: 'envelope_abc123',
|
||||
reason: 'The agreement is no longer needed.',
|
||||
}),
|
||||
});
|
||||
|
||||
const { success } = await response.json();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- Only documents in `PENDING` status can be cancelled. Other statuses return `400`.
|
||||
- Cancellation is not idempotent. Cancelling the same document again returns `400`.
|
||||
- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`.
|
||||
- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook.
|
||||
- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document.
|
||||
|
||||
---
|
||||
|
||||
## Delete Document
|
||||
|
||||
Delete a document. Completed documents cannot be deleted.
|
||||
@@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete',
|
||||
|
||||
const { success } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -680,7 +791,7 @@ const { success } = await response.json();
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -695,8 +806,10 @@ POST /envelope/get-many
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ----- | -------- | --------------------- |
|
||||
| `envelopeIds` | array | Yes | Array of document IDs |
|
||||
| ---------- | ------ | -------- | ---------------------------------------------------------------------------- |
|
||||
| `ids` | object | Yes | ID selector containing `type` and `ids` |
|
||||
| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` |
|
||||
| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` |
|
||||
|
||||
### Code Examples
|
||||
|
||||
@@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
"ids": {
|
||||
"type": "envelopeId",
|
||||
"ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```typescript
|
||||
const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'];
|
||||
|
||||
const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'],
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: requestedIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const documents = await response.json();
|
||||
const { data } = await response.json();
|
||||
|
||||
````
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "envelope_abc123",
|
||||
"type": "DOCUMENT",
|
||||
"status": "PENDING",
|
||||
"title": "Service Agreement"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions.
|
||||
|
||||
---
|
||||
|
||||
## Document Statuses
|
||||
@@ -740,6 +878,7 @@ const documents = await response.json();
|
||||
| `PENDING` | Document has been sent. Waiting for recipients to sign. |
|
||||
| `COMPLETED` | All recipients have signed. Document is sealed. |
|
||||
| `REJECTED` | A recipient rejected the document. |
|
||||
| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. |
|
||||
|
||||
### Status Transitions
|
||||
|
||||
@@ -747,11 +886,13 @@ const documents = await response.json();
|
||||
flowchart LR
|
||||
DRAFT --> PENDING --> COMPLETED
|
||||
PENDING --> REJECTED
|
||||
PENDING --> CANCELLED
|
||||
```
|
||||
|
||||
- **DRAFT to PENDING**: Call the distribute endpoint
|
||||
- **PENDING to COMPLETED**: All recipients complete their signing
|
||||
- **PENDING to REJECTED**: A recipient rejects the document
|
||||
- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document
|
||||
|
||||
<Callout type="warn">
|
||||
You cannot modify recipients or fields after a document moves to `PENDING` status.
|
||||
@@ -773,8 +914,8 @@ flowchart LR
|
||||
| Parameter | Values | Description |
|
||||
| ---------- | ------------------------------------------- | ------------------------- |
|
||||
| `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source |
|
||||
| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status |
|
||||
| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source |
|
||||
| `folderId` | string | Filter by folder |
|
||||
|
||||
### Sorting
|
||||
@@ -800,10 +941,10 @@ async function getAllPendingDocuments() {
|
||||
},
|
||||
);
|
||||
|
||||
const { data, pagination } = await response.json();
|
||||
const { data, currentPage, totalPages } = await response.json();
|
||||
documents.push(...data);
|
||||
|
||||
hasMore = page < pagination.totalPages;
|
||||
hasMore = currentPage < totalPages;
|
||||
page++;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) |
|
||||
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||
@@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||
| ------------------------------------- | ------------------------------------------------ |
|
||||
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) |
|
||||
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||
|
||||
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
|
||||
## HTTP Rate Limits
|
||||
|
||||
The rate limit applies to:
|
||||
|
||||
- `/api/v1/*`
|
||||
- `/api/v2/*`
|
||||
- `/api/v2-beta/*`
|
||||
|
||||
**Limit:** 1000 requests per minute per IP address
|
||||
**Response:** 429 Too Many Requests
|
||||
|
||||
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
this value, in which case you can be rate-limited before reaching the global limit.
|
||||
</Callout>
|
||||
|
||||
### Rate Limit Response
|
||||
### Global per-IP 429 Response
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
|
||||
seconds before retrying.
|
||||
</Callout>
|
||||
### Rate Limit Headers
|
||||
|
||||
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
|
||||
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
|
||||
and carry no rate limit headers:
|
||||
|
||||
| Header | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------- |
|
||||
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
|
||||
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
|
||||
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
|
||||
|
||||
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
|
||||
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
|
||||
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
|
||||
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
|
||||
|
||||
## Resource Limits
|
||||
|
||||
@@ -44,25 +62,56 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
|
||||
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
|
||||
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
|
||||
|
||||
### Error Response
|
||||
### Organisation Limit 429 Responses
|
||||
|
||||
When you exceed a resource limit:
|
||||
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
|
||||
shape depends on the API version, and neither matches the global per-IP limiter's
|
||||
`{ "error": "..." }` body.
|
||||
|
||||
On `/api/v1/*`, the body contains only a message:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "You have reached your document limit for this month. Please upgrade your plan.",
|
||||
"code": "LIMIT_EXCEEDED",
|
||||
"statusCode": 400
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
```
|
||||
|
||||
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"data": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"httpStatus": 429,
|
||||
"appError": {
|
||||
"code": "TOO_MANY_REQUESTS",
|
||||
"message": "Too many requests, please try again later. Contact support if you require higher limits."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
|
||||
their own window. Monthly quota responses carry no quota-specific rate limit headers or
|
||||
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Status | Description |
|
||||
| ------------------- | ------ | ----------------------------- |
|
||||
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
|
||||
| ------------------- | ------ | ------------------------------------------------------------------ |
|
||||
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
|
||||
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
|
||||
|
||||
There are three sources of `TOO_MANY_REQUESTS` responses:
|
||||
|
||||
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
|
||||
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
|
||||
3. Organisation monthly quotas for the same three counters. Every authenticated API request
|
||||
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
|
||||
exhausted — not just envelope-related ones.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
|
||||
// Retry on rate limit
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
|
||||
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||||
console.log(`Rate limited, waiting ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"fumadocs-core": "16.5.0",
|
||||
"fumadocs-mdx": "14.2.6",
|
||||
"fumadocs-ui": "16.5.0",
|
||||
"fumadocs-core": "16.14.3",
|
||||
"fumadocs-mdx": "15.2.3",
|
||||
"fumadocs-ui": "16.14.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.2.6",
|
||||
"next": "16.3.0",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "16.2.6"
|
||||
"next": "16.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react": "^19.2.17",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -32,6 +33,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
@@ -162,9 +164,12 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
);
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useState } from 'react';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||
@@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({
|
||||
} catch (err) {
|
||||
onDocumentError?.();
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to complete the document. Please try again.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { commandScore } from 'cmdk/dist/command-score';
|
||||
import { defaultFilter as commandScore } from 'cmdk';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
|
||||
+17
-1
@@ -14,6 +14,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
@@ -27,6 +28,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningCompleteDialogProps = {
|
||||
@@ -85,7 +88,8 @@ export const DocumentSigningCompleteDialog = ({
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
@@ -174,6 +178,18 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// This dialog owns the completion error toast for every signing surface
|
||||
// so the user gets a specific, actionable message. Callers should run
|
||||
// their own side effects (e.g. embeds posting document-error) and
|
||||
// rethrow rather than toasting themselves.
|
||||
const toastMessage = getSigningCompletionErrorMessage(err.code);
|
||||
|
||||
toast({
|
||||
title: i18n._(toastMessage.title),
|
||||
description: i18n._(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
@@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
@@ -100,9 +103,12 @@ export const DocumentSigningForm = ({
|
||||
try {
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
title: _(toastMessage.title),
|
||||
description: _(toastMessage.description),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
@@ -146,14 +146,17 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
{isLoading && <DocumentSigningFieldsLoader />}
|
||||
|
||||
{!field.inserted && (
|
||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10 my-0.5 gap-y-1">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => handleSelectItem(value)}
|
||||
className="z-10 my-0.5 gap-y-1"
|
||||
>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3 shrink-0"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.checked}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
@@ -167,14 +170,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<RadioGroup className="my-0.5 gap-y-1">
|
||||
<RadioGroup value={field.customText ?? ''} className="my-0.5 gap-y-1">
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="h-3 w-3"
|
||||
value={item.value}
|
||||
id={`option-${field.id}-${item.id}`}
|
||||
checked={item.value === field.customText}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!item.value.includes('empty-value-') && item.value && (
|
||||
|
||||
@@ -20,18 +20,20 @@ import {
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangleIcon, FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -79,7 +81,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -87,7 +90,32 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { revalidate } = useRevalidator();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
|
||||
trpc.envelope.field.importFromPdf.useMutation();
|
||||
|
||||
const currentEnvelopeItemRevision = currentEnvelopeItem
|
||||
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
|
||||
: null;
|
||||
const currentItemHasAcroForm =
|
||||
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
|
||||
|
||||
const onAcroFormDetected = useCallback(
|
||||
(hasFields: boolean) => {
|
||||
if (!currentEnvelopeItemRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAcroFormHasFieldsByItemRevision((prev) =>
|
||||
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
|
||||
);
|
||||
},
|
||||
[currentEnvelopeItemRevision],
|
||||
);
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
@@ -202,6 +230,40 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onImportFromPdfClick = async () => {
|
||||
try {
|
||||
await flushAutosave();
|
||||
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
|
||||
|
||||
if (result.fieldsCreated === 0) {
|
||||
toast({
|
||||
title: _(msg`No form fields found`),
|
||||
description: _(msg`This PDF does not contain any importable form fields.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await syncEnvelope();
|
||||
|
||||
toast({
|
||||
title: _(msg`Fields imported`),
|
||||
description: _(
|
||||
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Could not import fields`),
|
||||
description: _(msg`Something went wrong while importing fields from the PDF.`),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
|
||||
@@ -291,6 +353,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
onAcroFormDetected={onAcroFormDetected}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
@@ -383,6 +446,20 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => void onImportFromPdfClick()}
|
||||
disabled={isImportingFieldsFromPdf}
|
||||
>
|
||||
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
+4
-11
@@ -148,15 +148,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
}
|
||||
|
||||
// Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and
|
||||
// toast a specific completion error message.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -224,14 +220,11 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
// Rethrow so DocumentSigningCompleteDialog can toast a specific
|
||||
// completion error message.
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
|
||||
|
||||
return (
|
||||
<PDFViewerLazy
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
|
||||
{...props}
|
||||
className={cn('h-full w-full max-w-[800px]', className)}
|
||||
data={currentEnvelopeItem.data}
|
||||
|
||||
@@ -50,6 +50,7 @@ export type PDFViewerProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
|
||||
onDocumentLoad?: () => void;
|
||||
onAcroFormDetected?: (hasFields: boolean) => void;
|
||||
|
||||
/**
|
||||
* Additional component to render next to the image, such as a Konva canvas
|
||||
@@ -63,6 +64,7 @@ export default function PDFViewer({
|
||||
data,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
onAcroFormDetected,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) {
|
||||
@@ -124,6 +126,20 @@ export default function PDFViewer({
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pdfRef.current = loadedPdf;
|
||||
|
||||
if (onAcroFormDetected) {
|
||||
try {
|
||||
const fieldObjects = await loadedPdf.getFieldObjects();
|
||||
|
||||
if (!isCancelled) {
|
||||
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
onAcroFormDetected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the pages
|
||||
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
|
||||
const page = await loadedPdf.getPage(pageIndex + 1);
|
||||
@@ -168,7 +184,7 @@ export default function PDFViewer({
|
||||
pdfRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
}, [data, onAcroFormDetected]);
|
||||
|
||||
// Notify when document is loaded
|
||||
useEffect(() => {
|
||||
@@ -215,7 +231,7 @@ export default function PDFViewer({
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
constraintRef: React.RefObject<HTMLDivElement | null>;
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
pdf: pdfjsLib.PDFDocumentProxy;
|
||||
|
||||
@@ -31,6 +31,26 @@ function initPosthog() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces hydration recoveries (React 19 discards the server HTML and
|
||||
* re-renders on the client instead of dying) so we can track how often
|
||||
* extensions/early clicks interfere with hydration in the wild.
|
||||
*/
|
||||
function onRecoverableError(error: unknown, errorInfo: { componentStack?: string }) {
|
||||
console.error('[hydration] recovered from error', error, errorInfo.componentStack);
|
||||
|
||||
if (extractPostHogConfig()) {
|
||||
void import('posthog-js').then(({ default: posthog }) => {
|
||||
if (posthog.__loaded) {
|
||||
posthog.capture('$hydration_recoverable_error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||
|
||||
@@ -44,6 +64,7 @@ async function main() {
|
||||
<HydratedRouter />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
{ onRecoverableError },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,51 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast messages for errors thrown while a recipient attempts to complete
|
||||
* (sign) a document, so the user knows whether retrying can help and what to
|
||||
* do next.
|
||||
*/
|
||||
export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with(AppErrorCode.NOT_FOUND, () => ({
|
||||
title: msg`Document no longer available`,
|
||||
description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`,
|
||||
}))
|
||||
.with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({
|
||||
title: msg`Some fields were not saved`,
|
||||
description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`,
|
||||
}))
|
||||
.with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({
|
||||
title: msg`It's not your turn to sign yet`,
|
||||
description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`,
|
||||
}))
|
||||
.with(AppErrorCode.RECIPIENT_EXPIRED, () => ({
|
||||
title: msg`Signing link expired`,
|
||||
description: msg`Your signing link has expired. Please contact the sender to request a new one.`,
|
||||
}))
|
||||
.with(AppErrorCode.ENVELOPE_COMPLETED, () => ({
|
||||
title: msg`Document already completed`,
|
||||
description: msg`This document has already been completed and no further signatures can be added.`,
|
||||
}))
|
||||
.with(AppErrorCode.ENVELOPE_REJECTED, () => ({
|
||||
title: msg`Document rejected`,
|
||||
description: msg`This document has been rejected by a recipient and can no longer be signed.`,
|
||||
}))
|
||||
.with(AppErrorCode.ENVELOPE_CANCELLED, () => ({
|
||||
title: msg`Document cancelled`,
|
||||
description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`,
|
||||
}))
|
||||
.with(AppErrorCode.ENVELOPE_DRAFT, () => ({
|
||||
title: msg`Document not ready`,
|
||||
description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Something went wrong`,
|
||||
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
return match(code)
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
"papaparse": "^5.5.3",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-call": "^1.8.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
@@ -93,11 +93,11 @@
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.0",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"remix-flat-routes": "^0.8.5",
|
||||
"rollup": "^4.53.3",
|
||||
"tsx": "^4.23.1",
|
||||
|
||||
@@ -121,7 +121,7 @@ export default defineConfig({
|
||||
'nodemailer',
|
||||
/playwright/,
|
||||
'@playwright/browser-chromium',
|
||||
'skia-canvas',
|
||||
'@documenso/skia-canvas',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
%PDF-1.7
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [4 0 R 5 0 R]
|
||||
/Count 2
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 1 0 R
|
||||
/AcroForm 6 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Title (Untitled)
|
||||
/Author (Unknown)
|
||||
/Creator (@libpdf/core)
|
||||
/Producer (@libpdf/core)
|
||||
/CreationDate (D:20260521033345Z)
|
||||
/ModDate (D:20260521033345Z)
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 612 792]
|
||||
/Resources <<
|
||||
>>
|
||||
/Parent 1 0 R
|
||||
/Annots [8 0 R 11 0 R 14 0 R]
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 612 792]
|
||||
/Resources <<
|
||||
>>
|
||||
/Parent 1 0 R
|
||||
/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R]
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R]
|
||||
/DR <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
/ZaDb <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /ZapfDingbats
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/DA (/Helv 0 Tf 0 g)
|
||||
/NeedAppearances false
|
||||
/SigFlags 3
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/FT /Tx
|
||||
/T (CustomerName)
|
||||
/Kids [8 0 R]
|
||||
/DA (0 g)
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 620 280 644]
|
||||
/P 4 0 R
|
||||
/Parent 7 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 9 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Length 73/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 200 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 198 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 6.974 Td
|
||||
() Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/FT /Tx
|
||||
/T (signed_date)
|
||||
/Kids [11 0 R]
|
||||
/DA (0 g)
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 560 280 584]
|
||||
/P 4 0 R
|
||||
/Parent 10 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 12 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Length 73/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 200 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 198 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 6.974 Td
|
||||
() Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/FT /Btn
|
||||
/T (accept_terms)
|
||||
/Kids [14 0 R]
|
||||
/V /Off
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 500 98 518]
|
||||
/P 4 0 R
|
||||
/Parent 13 0 R
|
||||
/F 4
|
||||
/AS /Off
|
||||
/AP <<
|
||||
/N <<
|
||||
/Yes 15 0 R
|
||||
/Off 16 0 R
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Length 47/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 18 18]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/ZaDb <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /ZapfDingbats
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
BT
|
||||
/ZaDb 12.6 Tf
|
||||
0 g
|
||||
2.7 4.59 Td
|
||||
(4) Tj
|
||||
ET
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Length 0/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 18 18]
|
||||
/Resources <<
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
|
||||
endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/FT /Ch
|
||||
/T (country)
|
||||
/Ff 131072
|
||||
/Kids [18 0 R]
|
||||
/Opt [(USA) (Canada) (Germany)]
|
||||
/DA (0 g)
|
||||
/V (USA)
|
||||
/DV (USA)
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 700 280 724]
|
||||
/P 5 0 R
|
||||
/Parent 17 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 19 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Length 76/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 200 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 178 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 9.872 Td
|
||||
(USA) Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/FT /Btn
|
||||
/T (payment_method)
|
||||
/Ff 32768
|
||||
/Kids [21 0 R 24 0 R 27 0 R]
|
||||
/V /PayPal
|
||||
/DV /PayPal
|
||||
/Opt [(Credit Card) (PayPal) (Bank Transfer)]
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 640 96 656]
|
||||
/P 5 0 R
|
||||
/Parent 20 0 R
|
||||
/F 4
|
||||
/AS /Off
|
||||
/AP <<
|
||||
/N <<
|
||||
/Credit#20Card 22 0 R
|
||||
/Off 23 0 R
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Length 46/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/ZaDb <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /ZapfDingbats
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
BT
|
||||
/ZaDb 9.6 Tf
|
||||
0 g
|
||||
3.2 4.64 Td
|
||||
(l) Tj
|
||||
ET
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Length 159/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
0 G
|
||||
12.8 8 m
|
||||
12.8 10.65104 10.65104 12.8 8 12.8 c
|
||||
5.34896 12.8 3.2 10.65104 3.2 8 c
|
||||
3.2 5.34896 5.34896 3.2 8 3.2 c
|
||||
10.65104 3.2 12.8 5.34896 12.8 8 c
|
||||
h
|
||||
S
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 615 96 631]
|
||||
/P 5 0 R
|
||||
/Parent 20 0 R
|
||||
/F 4
|
||||
/AS /PayPal
|
||||
/AP <<
|
||||
/N <<
|
||||
/PayPal 25 0 R
|
||||
/Off 26 0 R
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Length 46/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/ZaDb <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /ZapfDingbats
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
BT
|
||||
/ZaDb 9.6 Tf
|
||||
0 g
|
||||
3.2 4.64 Td
|
||||
(l) Tj
|
||||
ET
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Length 159/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
0 G
|
||||
12.8 8 m
|
||||
12.8 10.65104 10.65104 12.8 8 12.8 c
|
||||
5.34896 12.8 3.2 10.65104 3.2 8 c
|
||||
3.2 5.34896 5.34896 3.2 8 3.2 c
|
||||
10.65104 3.2 12.8 5.34896 12.8 8 c
|
||||
h
|
||||
S
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 590 96 606]
|
||||
/P 5 0 R
|
||||
/Parent 20 0 R
|
||||
/F 4
|
||||
/AS /Off
|
||||
/AP <<
|
||||
/N <<
|
||||
/Bank#20Transfer 28 0 R
|
||||
/Off 29 0 R
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Length 46/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/ZaDb <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /ZapfDingbats
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
BT
|
||||
/ZaDb 9.6 Tf
|
||||
0 g
|
||||
3.2 4.64 Td
|
||||
(l) Tj
|
||||
ET
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Length 159/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 16 16]
|
||||
/Resources <<
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
q
|
||||
0 G
|
||||
12.8 8 m
|
||||
12.8 10.65104 10.65104 12.8 8 12.8 c
|
||||
5.34896 12.8 3.2 10.65104 3.2 8 c
|
||||
3.2 5.34896 5.34896 3.2 8 3.2 c
|
||||
10.65104 3.2 12.8 5.34896 12.8 8 c
|
||||
h
|
||||
S
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/FT /Tx
|
||||
/T (initials)
|
||||
/Kids [31 0 R]
|
||||
/DA (0 g)
|
||||
>>
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [80 540 140 564]
|
||||
/P 5 0 R
|
||||
/Parent 30 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 32 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Length 72/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 60 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 58 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 6.974 Td
|
||||
() Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/FT /Tx
|
||||
/T (contact_email)
|
||||
/Kids [34 0 R]
|
||||
/DA (0 g)
|
||||
>>
|
||||
endobj
|
||||
34 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [160 540 380 564]
|
||||
/P 5 0 R
|
||||
/Parent 33 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 35 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
35 0 obj
|
||||
<<
|
||||
/Length 73/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 220 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 218 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 6.974 Td
|
||||
() Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
36 0 obj
|
||||
<<
|
||||
/FT /Tx
|
||||
/T (item_qty)
|
||||
/Kids [37 0 R]
|
||||
/DA (0 g)
|
||||
/MaxLen 4
|
||||
>>
|
||||
endobj
|
||||
37 0 obj
|
||||
<<
|
||||
/Type /Annot
|
||||
/Subtype /Widget
|
||||
/Rect [400 540 460 564]
|
||||
/P 5 0 R
|
||||
/Parent 36 0 R
|
||||
/F 4
|
||||
/AP <<
|
||||
/N 38 0 R
|
||||
>>
|
||||
>>
|
||||
endobj
|
||||
38 0 obj
|
||||
<<
|
||||
/Length 72/Type /XObject
|
||||
/Subtype /Form
|
||||
/BBox [0 0 60 24]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/Helv <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
stream
|
||||
/Tx BMC
|
||||
q
|
||||
1 1 58 22 re
|
||||
W
|
||||
n
|
||||
BT
|
||||
/Helv 14 Tf
|
||||
0 g
|
||||
2 6.974 Td
|
||||
() Tj
|
||||
ET
|
||||
Q
|
||||
EMC
|
||||
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 39
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000078 00000 n
|
||||
0000000143 00000 n
|
||||
0000000312 00000 n
|
||||
0000000430 00000 n
|
||||
0000000577 00000 n
|
||||
0000000866 00000 n
|
||||
0000000937 00000 n
|
||||
0000001058 00000 n
|
||||
0000001319 00000 n
|
||||
0000001391 00000 n
|
||||
0000001515 00000 n
|
||||
0000001777 00000 n
|
||||
0000001849 00000 n
|
||||
0000002004 00000 n
|
||||
0000002242 00000 n
|
||||
0000002356 00000 n
|
||||
0000002486 00000 n
|
||||
0000002610 00000 n
|
||||
0000002875 00000 n
|
||||
0000003034 00000 n
|
||||
0000003199 00000 n
|
||||
0000003436 00000 n
|
||||
0000003711 00000 n
|
||||
0000003872 00000 n
|
||||
0000004109 00000 n
|
||||
0000004384 00000 n
|
||||
0000004551 00000 n
|
||||
0000004788 00000 n
|
||||
0000005063 00000 n
|
||||
0000005132 00000 n
|
||||
0000005256 00000 n
|
||||
0000005516 00000 n
|
||||
0000005590 00000 n
|
||||
0000005715 00000 n
|
||||
0000005977 00000 n
|
||||
0000006056 00000 n
|
||||
0000006181 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 39
|
||||
/Root 2 0 R
|
||||
/Info 3 0 R
|
||||
/ID [<4798EA6ACCB2CE19827C36CD841F759A> <4798EA6ACCB2CE19827C36CD841F759A>]
|
||||
>>
|
||||
startxref
|
||||
6441
|
||||
%%EOF
|
||||
@@ -76,7 +76,6 @@ COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||
|
||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||
COPY --from=builder /app/patches ./patches
|
||||
|
||||
RUN npm ci
|
||||
|
||||
@@ -115,8 +114,6 @@ WORKDIR /app
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||
# Copy the tailwind config files across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
||||
# Copy the patches across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
|
||||
Generated
+3849
-4782
File diff suppressed because it is too large
Load Diff
+44
-7
@@ -48,20 +48,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@datadog/pprof": "^5.13.5",
|
||||
"@documenso/skia-canvas": "^3.0.8-documenso.3",
|
||||
"@lingui/cli": "^5.6.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@trpc/client": "11.8.1",
|
||||
"@trpc/react-query": "11.8.1",
|
||||
"@trpc/server": "11.8.1",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@ts-rest/open-api": "^3.52.1",
|
||||
"@ts-rest/serverless": "^3.52.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"husky": "^9.1.7",
|
||||
"inngest": "^3.54.0",
|
||||
"inngest-cli": "^1.17.9",
|
||||
@@ -86,27 +89,61 @@
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"ai": "^5.0.104",
|
||||
"@radix-ui/react-accordion": "^1.2.16",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.19",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.11",
|
||||
"@radix-ui/react-avatar": "^1.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-collapsible": "^1.1.16",
|
||||
"@radix-ui/react-context-menu": "^2.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-hover-card": "^1.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-menubar": "^1.1.20",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.18",
|
||||
"@radix-ui/react-popover": "^1.1.19",
|
||||
"@radix-ui/react-progress": "^1.1.12",
|
||||
"@radix-ui/react-radio-group": "^1.4.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slider": "^1.4.3",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.17",
|
||||
"@radix-ui/react-toast": "^1.2.19",
|
||||
"@radix-ui/react-toggle": "^1.1.14",
|
||||
"@radix-ui/react-toggle-group": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"ai": "^7.0.58",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fflate": "^0.8.3",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "0.35.3",
|
||||
"typescript": "5.6.2",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash": "4.18.1",
|
||||
"brace-expansion@1": "^1.1.18",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"postcss": "^8.5.19",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "$zod",
|
||||
"fumadocs-mdx": {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
||||
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { PDF, PdfString } from '@libpdf/core';
|
||||
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
|
||||
|
||||
const ACROFORM_DOCUMENT_PAYLOAD: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'AcroForm document',
|
||||
recipients: [
|
||||
{
|
||||
email: 'signer@example.com',
|
||||
name: 'Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const API_REQUEST_METADATA: ApiRequestMetadata = {
|
||||
requestMetadata: {},
|
||||
source: 'apiV1',
|
||||
auth: 'api',
|
||||
};
|
||||
|
||||
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
|
||||
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
|
||||
|
||||
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
return { token, user, team };
|
||||
};
|
||||
|
||||
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
return (form?.fieldCount ?? 0) > 0;
|
||||
};
|
||||
|
||||
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
|
||||
const pdf = PDF.create();
|
||||
const page = pdf.addPage({ size: 'letter' });
|
||||
const form = pdf.getOrCreateForm();
|
||||
const textField = form.createTextField('full_name');
|
||||
const signatureField = form.createSignatureField('signed_signature');
|
||||
|
||||
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
|
||||
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
|
||||
|
||||
return pdf.save();
|
||||
};
|
||||
|
||||
const uploadAcroFormEnvelope = async ({
|
||||
request,
|
||||
token,
|
||||
payload = ACROFORM_DOCUMENT_PAYLOAD,
|
||||
file = ACROFORM_FIXTURE,
|
||||
fileName = 'acroform-import-test.pdf',
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
payload?: TCreateEnvelopePayload;
|
||||
file?: Uint8Array;
|
||||
fileName?: string;
|
||||
}): Promise<TCreateEnvelopeResponse> => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
const importAcroFormFieldsWithSession = ({
|
||||
page,
|
||||
teamId,
|
||||
envelopeId,
|
||||
}: {
|
||||
page: Page;
|
||||
teamId: number;
|
||||
envelopeId: string;
|
||||
}) =>
|
||||
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-team-id': String(teamId),
|
||||
},
|
||||
data: JSON.stringify({ json: { envelopeId } }),
|
||||
});
|
||||
|
||||
const loadEnvelopeForImport = async (envelopeId: string) =>
|
||||
prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('AcroForm Import', () => {
|
||||
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
|
||||
const { token } = await seedUserWithApiToken();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({ request, token });
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.fields).toHaveLength(0);
|
||||
|
||||
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
|
||||
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
|
||||
const { token, user } = await seedUserWithApiToken();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({ request, token });
|
||||
|
||||
const envelope = await loadEnvelopeForImport(response.id);
|
||||
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||
|
||||
await UNSAFE_replaceEnvelopeItemPdf({
|
||||
envelope,
|
||||
recipients: envelope.recipients,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
oldDocumentDataId,
|
||||
data: {
|
||||
title: 'Replacement AcroForm document',
|
||||
file: new File([ACROFORM_FIXTURE], 'replacement-acroform.pdf', { type: 'application/pdf' }),
|
||||
},
|
||||
user,
|
||||
apiRequestMetadata: API_REQUEST_METADATA,
|
||||
});
|
||||
|
||||
const after = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(after.fields).toHaveLength(0);
|
||||
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
|
||||
|
||||
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
|
||||
const { token } = await seedUserWithApiToken();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({ request, token });
|
||||
|
||||
const envelope = await loadEnvelopeForImport(response.id);
|
||||
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||
|
||||
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||
envelope,
|
||||
apiRequestMetadata: API_REQUEST_METADATA,
|
||||
});
|
||||
|
||||
expect(result.fieldsCreated).toBeGreaterThan(0);
|
||||
expect(result.itemsProcessed).toBe(1);
|
||||
|
||||
const after = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
||||
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
||||
|
||||
for (const field of after.fields) {
|
||||
const meta = field.fieldMeta as { source?: string } | null;
|
||||
expect(meta?.source).toBe('acroform');
|
||||
}
|
||||
|
||||
const auditEntries = await prisma.documentAuditLog.findMany({
|
||||
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
|
||||
});
|
||||
|
||||
expect(auditEntries.length).toBe(after.fields.length);
|
||||
|
||||
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
|
||||
|
||||
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||
|
||||
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
|
||||
|
||||
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
|
||||
|
||||
expect(oldRecord).toBeNull();
|
||||
});
|
||||
|
||||
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
|
||||
const { token } = await seedUserWithApiToken();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({
|
||||
request,
|
||||
token,
|
||||
payload: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'AcroForm document without recipients',
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await loadEnvelopeForImport(response.id);
|
||||
|
||||
expect(envelope.recipients).toHaveLength(0);
|
||||
|
||||
await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||
envelope,
|
||||
apiRequestMetadata: API_REQUEST_METADATA,
|
||||
});
|
||||
|
||||
const after = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: { recipients: true, fields: true },
|
||||
});
|
||||
|
||||
expect(after.recipients).toHaveLength(1);
|
||||
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
|
||||
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
|
||||
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
||||
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
|
||||
const { token, user, team } = await seedUserWithApiToken();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({
|
||||
request,
|
||||
token,
|
||||
payload: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'AcroForm template',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email });
|
||||
|
||||
const res = await importAcroFormFieldsWithSession({
|
||||
page,
|
||||
teamId: team.id,
|
||||
envelopeId: response.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const after = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(after.fields).toHaveLength(0);
|
||||
|
||||
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
|
||||
const { token } = await seedUserWithApiToken();
|
||||
const signedPdf = await createSignedSignatureAcroFormPdf();
|
||||
|
||||
const response = await uploadAcroFormEnvelope({
|
||||
request,
|
||||
token,
|
||||
file: signedPdf,
|
||||
fileName: 'signed-acroform.pdf',
|
||||
});
|
||||
|
||||
const envelope = await loadEnvelopeForImport(response.id);
|
||||
|
||||
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||
envelope,
|
||||
apiRequestMetadata: API_REQUEST_METADATA,
|
||||
});
|
||||
|
||||
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
|
||||
expect(firstResult.itemsProcessed).toBe(1);
|
||||
expect(firstResult.signedSignatureCount).toBe(1);
|
||||
|
||||
const afterFirst = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
const firstFieldCount = afterFirst.fields.length;
|
||||
|
||||
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
|
||||
|
||||
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
|
||||
|
||||
const secondEnvelope = await loadEnvelopeForImport(response.id);
|
||||
|
||||
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||
envelope: secondEnvelope,
|
||||
apiRequestMetadata: API_REQUEST_METADATA,
|
||||
});
|
||||
|
||||
expect(secondResult.fieldsCreated).toBe(0);
|
||||
expect(secondResult.itemsProcessed).toBe(0);
|
||||
|
||||
const afterSecond = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: response.id },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(afterSecond.fields).toHaveLength(firstFieldCount);
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
|
||||
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
|
||||
|
||||
test.describe('Envelope Creation (DOCUMENT type)', () => {
|
||||
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
|
||||
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -136,16 +136,16 @@ test.describe('Form Flattening', () => {
|
||||
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
|
||||
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||
|
||||
// Get the PDF and verify form fields are flattened
|
||||
const documentData = envelope.envelopeItems[0].documentData;
|
||||
const pdfBuffer = await getFileServerSide(documentData);
|
||||
|
||||
const hasFormFields = await pdfHasFormFields(pdfBuffer);
|
||||
|
||||
expect(hasFormFields).toBe(false);
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
|
||||
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
|
||||
);
|
||||
});
|
||||
|
||||
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
|
||||
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
@@ -157,7 +157,6 @@ test.describe('Form Flattening', () => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document without Form Values',
|
||||
// No formValues - but form should still be flattened for DOCUMENT type
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -184,13 +183,10 @@ test.describe('Form Flattening', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Get the PDF and verify form fields are flattened
|
||||
const documentData = envelope.envelopeItems[0].documentData;
|
||||
const pdfBuffer = await getFileServerSide(documentData);
|
||||
|
||||
const hasFormFields = await pdfHasFormFields(pdfBuffer);
|
||||
|
||||
expect(hasFormFields).toBe(false);
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,11 +743,10 @@ test.describe('Form Flattening', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Form should still be flattened for DOCUMENT type
|
||||
const documentData = envelope.envelopeItems[0].documentData;
|
||||
const pdfBuffer = await getFileServerSide(documentData);
|
||||
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle partial formValues (only some fields)', async ({ request }) => {
|
||||
@@ -798,11 +793,11 @@ test.describe('Form Flattening', () => {
|
||||
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
|
||||
});
|
||||
|
||||
// Form should still be flattened
|
||||
const documentData = envelope.envelopeItems[0].documentData;
|
||||
const pdfBuffer = await getFileServerSide(documentData);
|
||||
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
|
||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export { Body } from '@react-email/body';
|
||||
export { Button } from '@react-email/button';
|
||||
export { Column } from '@react-email/column';
|
||||
export { Container } from '@react-email/container';
|
||||
export { Font } from '@react-email/font';
|
||||
export { Head } from '@react-email/head';
|
||||
export { Heading } from '@react-email/heading';
|
||||
export { Hr } from '@react-email/hr';
|
||||
export { Html } from '@react-email/html';
|
||||
export { Img } from '@react-email/img';
|
||||
export { Link } from '@react-email/link';
|
||||
export { Preview } from '@react-email/preview';
|
||||
export { render } from '@react-email/render';
|
||||
export { Row } from '@react-email/row';
|
||||
export { Section } from '@react-email/section';
|
||||
export { Tailwind } from '@react-email/tailwind';
|
||||
export { Text } from '@react-email/text';
|
||||
export {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
render,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from 'react-email';
|
||||
|
||||
@@ -19,27 +19,9 @@
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "5.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"@react-email/render": "2.1.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"react-email": "^5.0.6",
|
||||
"react-email": "^6.9.0",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
|
||||
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
|
||||
const { delay = 2000 } = options;
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import type React from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
/**
|
||||
* The envelope is kept in a ref-backed external store instead of useState so
|
||||
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
|
||||
* can synchronously read the latest value via `getEnvelope`.
|
||||
*
|
||||
* React subscribes to the store through useSyncExternalStore, keeping renders in
|
||||
* sync without maintaining a separate copy of the state.
|
||||
*/
|
||||
const envelopeStoreRef = useRef(initialEnvelope);
|
||||
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
|
||||
|
||||
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
|
||||
envelopeStoreSubscribersRef.current.add(onStoreChange);
|
||||
|
||||
return () => {
|
||||
envelopeStoreSubscribersRef.current.delete(onStoreChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
|
||||
|
||||
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
|
||||
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
|
||||
|
||||
envelopeStoreRef.current = next;
|
||||
|
||||
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
|
||||
onStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
}, [isCscMode, providedEditorConfig]);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
|
||||
|
||||
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||
_setEnvelope((prev) => {
|
||||
const next = typeof action === 'function' ? action(prev) : action;
|
||||
envelopeRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isEmbedded = editorConfig.embedded !== undefined;
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
recipients: localRecipients,
|
||||
});
|
||||
|
||||
recipients = response.data;
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
|
||||
}));
|
||||
|
||||
// Reset the local fields to ensure deleted recipient fields are removed.
|
||||
editorFields.resetForm(
|
||||
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
|
||||
);
|
||||
editorFields.resetForm(getEnvelope().fields);
|
||||
|
||||
setAutosaveError(false);
|
||||
} catch (err) {
|
||||
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
if (!isEmbedded) {
|
||||
const response = await setFieldsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
|
||||
fields = response.data;
|
||||
} else {
|
||||
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
|
||||
}
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
|
||||
try {
|
||||
const response = !isEmbedded
|
||||
? await updateEnvelopeMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeId: getEnvelope().id,
|
||||
data,
|
||||
meta,
|
||||
})
|
||||
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
const currentEnvelope = getEnvelope();
|
||||
|
||||
editorRecipients.resetForm({
|
||||
recipients: envelopeRef.current.recipients,
|
||||
documentMeta: envelopeRef.current.documentMeta,
|
||||
recipients: currentEnvelope.recipients,
|
||||
documentMeta: currentEnvelope.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(envelopeRef.current.fields);
|
||||
editorFields.resetForm(currentEnvelope.fields);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
|
||||
await Promise.allSettled(Array.from(pendingMutationsRef.current));
|
||||
}
|
||||
|
||||
return envelopeRef.current;
|
||||
return getEnvelope();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,7 @@ type EnvelopeRenderItem = {
|
||||
title: string;
|
||||
order: number;
|
||||
envelopeId: string;
|
||||
documentDataId: string;
|
||||
|
||||
/**
|
||||
* The PDF data to render.
|
||||
|
||||
@@ -43,6 +43,20 @@ export enum AppErrorCode {
|
||||
*/
|
||||
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
|
||||
|
||||
/**
|
||||
* A completion request was made for a recipient that still has required
|
||||
* fields which have not been inserted. Usually indicates the client's field
|
||||
* state is out of sync with the server (e.g. a field insert failed to
|
||||
* persist before submission).
|
||||
*/
|
||||
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
|
||||
|
||||
/**
|
||||
* A completion request was made by a recipient in a sequential signing flow
|
||||
* before the preceding recipients have signed.
|
||||
*/
|
||||
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
|
||||
|
||||
/**
|
||||
* A signer recipient does not have a signature field assigned. Thrown when
|
||||
* distributing an envelope or using a direct template where at least one
|
||||
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[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.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { 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 },
|
||||
@@ -307,6 +323,8 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
|
||||
AppErrorCode.RECIPIENT_OUT_OF_TURN,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@aws-sdk/client-s3": "^3.998.0",
|
||||
"@aws-sdk/client-sesv2": "^3.998.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.998.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@documenso/skia-canvas": "^3.0.8-documenso.3",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
@@ -42,7 +43,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"ai": "^7.0.58",
|
||||
"bullmq": "^5.71.1",
|
||||
"colord": "^2.9.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
@@ -64,10 +65,9 @@
|
||||
"postcss-selector-parser": "^7.1.4",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"sharp": "0.35.3",
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
|
||||
import pMap from 'p-map';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { Canvas, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
globalThis.Path2D = Path2D;
|
||||
|
||||
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
@@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// The most common cause is a stale signing page: the document was deleted,
|
||||
// or the recipient was removed, after the link was opened. Surface a
|
||||
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found for the provided signing token',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Document ${envelope.id} has no recipient with the provided token`,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
|
||||
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
|
||||
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
|
||||
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
|
||||
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
|
||||
// Unreachable: guarded by the status check above.
|
||||
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
|
||||
};
|
||||
|
||||
throw new AppError(envelopeStatusErrorCode[envelope.status], {
|
||||
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
@@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
|
||||
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
|
||||
message: `Recipient ${recipient.id} has unsigned fields`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const UNSAFE_createEnvelopeItems = async ({
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
flattenForm: false,
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { nanoid } from '../../universal/id';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
type AcroFormExtractionResult,
|
||||
type AcroFormSkipReason,
|
||||
convertAcroFormFieldsToFieldInputs,
|
||||
extractAcroFormFieldsFromPDF,
|
||||
} from '../pdf/acroform-fields';
|
||||
import { normalizePdf } from '../pdf/normalize-pdf';
|
||||
|
||||
type UnsafeImportAcroFormFieldsOptions = {
|
||||
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
|
||||
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
|
||||
documentData: DocumentData;
|
||||
})[];
|
||||
recipients: Recipient[];
|
||||
};
|
||||
apiRequestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
type PerItemSkip = {
|
||||
envelopeItemId: string;
|
||||
envelopeItemTitle: string;
|
||||
reason: AcroFormSkipReason;
|
||||
};
|
||||
|
||||
export type ImportAcroFormFieldsResult = {
|
||||
itemsProcessed: number;
|
||||
fieldsCreated: number;
|
||||
unsupportedCount: number;
|
||||
signedSignatureCount: number;
|
||||
skippedItems: PerItemSkip[];
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
type PreparedItem = {
|
||||
envelopeItemId: string;
|
||||
envelopeItemTitle: string;
|
||||
oldDocumentDataId: string;
|
||||
extraction: AcroFormExtractionResult;
|
||||
newDocumentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
|
||||
envelope,
|
||||
apiRequestMetadata,
|
||||
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
|
||||
if (envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AcroForm import is only supported for document envelopes',
|
||||
});
|
||||
}
|
||||
|
||||
const prepared: PreparedItem[] = await Promise.all(
|
||||
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
|
||||
const buffer = await getFileServerSide(item.documentData);
|
||||
|
||||
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
|
||||
formValuesProvided: Boolean(envelope.formValues),
|
||||
});
|
||||
|
||||
if (extraction.skipReason) {
|
||||
logger.info(
|
||||
{
|
||||
event: 'acroform-import.skip',
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
reason: extraction.skipReason,
|
||||
},
|
||||
'AcroForm extraction skipped',
|
||||
);
|
||||
}
|
||||
|
||||
if (extraction.unsupported.length > 0) {
|
||||
const byReason: Record<string, number> = {};
|
||||
|
||||
for (const entry of extraction.unsupported) {
|
||||
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
event: 'acroform-import.unsupported',
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
count: extraction.unsupported.length,
|
||||
byReason,
|
||||
},
|
||||
'AcroForm import skipped unsupported widgets',
|
||||
);
|
||||
}
|
||||
|
||||
if (extraction.hasSignedSignature) {
|
||||
logger.warn(
|
||||
{
|
||||
event: 'acroform-import.signed-pdf-no-flatten',
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
},
|
||||
'Signed AcroForm signature detected — skipping flatten to preserve signature',
|
||||
);
|
||||
}
|
||||
|
||||
const base: PreparedItem = {
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
oldDocumentDataId: item.documentDataId,
|
||||
extraction,
|
||||
};
|
||||
|
||||
if (extraction.fields.length === 0 || extraction.hasSignedSignature) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const flattened = await normalizePdf(Buffer.from(buffer), {
|
||||
flattenForm: true,
|
||||
});
|
||||
|
||||
const { documentData: newDocumentData } = await putPdfFileServerSide({
|
||||
name: item.title,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(flattened),
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
newDocumentData,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
|
||||
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
|
||||
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
|
||||
|
||||
const skippedItems: PerItemSkip[] = [];
|
||||
|
||||
for (const p of prepared) {
|
||||
const reason = p.extraction.skipReason;
|
||||
|
||||
if (!reason) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skippedItems.push({
|
||||
envelopeItemId: p.envelopeItemId,
|
||||
envelopeItemTitle: p.envelopeItemTitle,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
if (totalFieldsToCreate === 0) {
|
||||
return {
|
||||
itemsProcessed: 0,
|
||||
fieldsCreated: 0,
|
||||
unsupportedCount,
|
||||
signedSignatureCount,
|
||||
skippedItems,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
|
||||
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
|
||||
const signable = recipients.filter((r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER);
|
||||
|
||||
if (signable.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return signable.sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
})[0];
|
||||
};
|
||||
|
||||
const signedItemIds = prepared
|
||||
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
|
||||
.map((item) => item.envelopeItemId);
|
||||
|
||||
const alreadyImportedSignedItemIds = new Set<string>();
|
||||
|
||||
if (signedItemIds.length > 0) {
|
||||
const existingImportedFields = await tx.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: {
|
||||
in: signedItemIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
envelopeItemId: true,
|
||||
fieldMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const field of existingImportedFields) {
|
||||
const fieldMeta = field.fieldMeta;
|
||||
|
||||
if (
|
||||
fieldMeta &&
|
||||
typeof fieldMeta === 'object' &&
|
||||
!Array.isArray(fieldMeta) &&
|
||||
(fieldMeta as { source?: unknown }).source === 'acroform'
|
||||
) {
|
||||
alreadyImportedSignedItemIds.add(field.envelopeItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemsToImport = prepared.filter((item) => {
|
||||
if (item.extraction.fields.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
|
||||
});
|
||||
|
||||
const createdFields: Field[] = [];
|
||||
|
||||
if (itemsToImport.length === 0) {
|
||||
return { createdFields, importedItemsCount: 0 };
|
||||
}
|
||||
|
||||
let recipient = pickFirstSignableRecipient(
|
||||
await tx.recipient.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
select: { id: true, email: true, role: true, signingOrder: true },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
const placeholderEmail = 'recipient.1@documenso.com';
|
||||
|
||||
recipient = await tx.recipient.create({
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
email: placeholderEmail,
|
||||
name: 'Recipient 1',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
token: nanoid(),
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
select: { id: true, email: true, role: true, signingOrder: true },
|
||||
});
|
||||
}
|
||||
|
||||
let importedItemsCount = 0;
|
||||
|
||||
for (const item of itemsToImport) {
|
||||
if (item.newDocumentData) {
|
||||
await tx.envelopeItem.update({
|
||||
where: { id: item.envelopeItemId },
|
||||
data: { documentDataId: item.newDocumentData.id },
|
||||
});
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
|
||||
item.extraction.fields,
|
||||
() => recipient,
|
||||
item.envelopeItemId,
|
||||
);
|
||||
|
||||
const itemCreatedFields = await tx.field.createManyAndReturn({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
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,
|
||||
})),
|
||||
});
|
||||
|
||||
createdFields.push(...itemCreatedFields);
|
||||
importedItemsCount += 1;
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: itemCreatedFields.map((createdField) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: apiRequestMetadata,
|
||||
data: {
|
||||
fieldId: createdField.secondaryId,
|
||||
fieldRecipientEmail: recipient.email,
|
||||
fieldRecipientId: createdField.recipientId,
|
||||
fieldType: createdField.type,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { createdFields, importedItemsCount };
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
prepared
|
||||
.filter((p) => p.newDocumentData !== undefined)
|
||||
.map((p) =>
|
||||
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
|
||||
logger.error(
|
||||
{
|
||||
event: 'acroform-import.delete-old-document-data-failed',
|
||||
envelopeItemId: p.envelopeItemId,
|
||||
oldDocumentDataId: p.oldDocumentDataId,
|
||||
err,
|
||||
},
|
||||
'Failed to delete orphaned DocumentData after AcroForm import',
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
itemsProcessed: importedItemsCount,
|
||||
fieldsCreated: createdFields.length,
|
||||
unsupportedCount,
|
||||
signedSignatureCount,
|
||||
skippedItems,
|
||||
fields: createdFields,
|
||||
};
|
||||
};
|
||||
@@ -82,7 +82,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
flattenForm: false,
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* !: This is a workaround to fix the memory leak in the skia-canvas library.
|
||||
* !: Internals are ported from the original `konva/skia-backend.js` file.
|
||||
*/
|
||||
|
||||
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
|
||||
import { Konva } from 'konva/lib/_CoreInternals';
|
||||
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error skia-canvas satisfies the requirements
|
||||
global.DOMMatrix = DOMMatrix;
|
||||
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
|
||||
return node as unknown as HTMLImageElement;
|
||||
};
|
||||
|
||||
Konva._renderBackend = 'skia-canvas';
|
||||
Konva._renderBackend = '@documenso/skia-canvas';
|
||||
|
||||
export default Konva;
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
import {
|
||||
PDF,
|
||||
type PDFPage,
|
||||
type PdfDict,
|
||||
type PdfObject,
|
||||
type PdfRef,
|
||||
type PdfStream,
|
||||
type PdfString,
|
||||
} from '@libpdf/core';
|
||||
import { FieldType, type Recipient } from '@prisma/client';
|
||||
import {
|
||||
FIELD_CHECKBOX_META_DEFAULT_VALUES,
|
||||
FIELD_DATE_META_DEFAULT_VALUES,
|
||||
FIELD_DROPDOWN_META_DEFAULT_VALUES,
|
||||
FIELD_EMAIL_META_DEFAULT_VALUES,
|
||||
FIELD_INITIALS_META_DEFAULT_VALUES,
|
||||
FIELD_NAME_META_DEFAULT_VALUES,
|
||||
FIELD_NUMBER_META_DEFAULT_VALUES,
|
||||
FIELD_RADIO_META_DEFAULT_VALUES,
|
||||
FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
FIELD_TEXT_META_DEFAULT_VALUES,
|
||||
type TCheckboxFieldMeta,
|
||||
type TDropdownFieldMeta,
|
||||
type TFieldAndMeta,
|
||||
type TNumberFieldMeta,
|
||||
type TRadioFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
ZEnvelopeFieldAndMetaSchema,
|
||||
} from '../../types/field-meta';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { FieldToCreate } from './auto-place-fields';
|
||||
|
||||
/**
|
||||
* Local shape for the widget annotations returned by @libpdf/core.
|
||||
*
|
||||
* The library exposes WidgetAnnotation as a class but does not re-export the
|
||||
* type from its public surface. We duck-type the subset we actually read.
|
||||
*/
|
||||
type WidgetAnnotation = {
|
||||
readonly rect: [number, number, number, number];
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly pageRef: PdfRef | null;
|
||||
isHidden(): boolean;
|
||||
getOnValue(): string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function shape that follows a {@link PdfRef} to the referenced object.
|
||||
*
|
||||
* `@libpdf/core` does not re-export the `RefResolver` type alias, so we redeclare
|
||||
* it locally. Built via {@link makeResolver} from a loaded {@link PDF}.
|
||||
*/
|
||||
type RefResolver = (ref: PdfRef) => PdfObject | null;
|
||||
|
||||
const makeResolver =
|
||||
(pdfDoc: PDF): RefResolver =>
|
||||
(ref: PdfRef) =>
|
||||
pdfDoc.context.resolve(ref);
|
||||
|
||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||
const MIN_HEIGHT_THRESHOLD = 0.01;
|
||||
|
||||
const DATE_NAME_PATTERN = /date|dob|birth/i;
|
||||
const NUMBER_NAME_PATTERN = /amount|qty|count|number/i;
|
||||
const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i;
|
||||
const NAME_NAME_PATTERN = /name/i;
|
||||
const INITIALS_NAME_PATTERN = /initial/i;
|
||||
|
||||
const ROW_TOLERANCE_PERCENT = 2;
|
||||
const ACROFORM_FIELD_SOURCE = 'acroform';
|
||||
|
||||
export type AcroFormUnsupportedReason =
|
||||
| 'unsupported-type'
|
||||
| 'hidden'
|
||||
| 'off-page'
|
||||
| 'zero-size'
|
||||
| 'no-page-match'
|
||||
| 'signed-signature'
|
||||
| 'rotated-out-of-bounds';
|
||||
|
||||
export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
|
||||
|
||||
export type AcroFormFieldImportInfo = {
|
||||
source: 'acroform';
|
||||
fieldName: string;
|
||||
widgetIndex: number;
|
||||
fieldAndMeta: TFieldAndMeta;
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type AcroFormUnsupportedFieldInfo = {
|
||||
fieldName: string;
|
||||
acroFormType: string;
|
||||
reason: AcroFormUnsupportedReason;
|
||||
};
|
||||
|
||||
export type AcroFormExtractionResult = {
|
||||
fields: AcroFormFieldImportInfo[];
|
||||
unsupported: AcroFormUnsupportedFieldInfo[];
|
||||
/**
|
||||
* True when a signed signature widget was found.
|
||||
*
|
||||
* Callers MUST set `flattenForm: false` for that envelope item so the signed
|
||||
* PDF is not re-flattened (which would invalidate the signature).
|
||||
*/
|
||||
hasSignedSignature: boolean;
|
||||
/**
|
||||
* Set when extraction returned empty for a reason that should be surfaced in
|
||||
* logs but not propagated to the user. Absent when extraction ran normally.
|
||||
*/
|
||||
skipReason?: AcroFormSkipReason;
|
||||
};
|
||||
|
||||
type ResolvedGeometry = {
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({
|
||||
fields: [],
|
||||
unsupported: [],
|
||||
hasSignedSignature: false,
|
||||
skipReason,
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect XFA-hybrid PDFs by inspecting the catalog's `/AcroForm` dict for an
|
||||
* `/XFA` key.
|
||||
*
|
||||
* Uses public accessors (`pdf.context.catalog.getDict()`) and a {@link RefResolver}
|
||||
* so an indirect `/AcroForm` entry is followed. Returns `false` on any error
|
||||
* (e.g. malformed catalog) so the caller can fall through to normal extraction.
|
||||
*/
|
||||
const hasXfa = (pdfDoc: PDF, resolver: RefResolver): boolean => {
|
||||
try {
|
||||
const catalogDict = pdfDoc.context.catalog.getDict();
|
||||
const acroFormDict = catalogDict.getDict('AcroForm', resolver);
|
||||
|
||||
return Boolean(acroFormDict?.has('XFA'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isDateFieldByName = (name: string | null | undefined): boolean => !!name && DATE_NAME_PATTERN.test(name);
|
||||
|
||||
const isNumberFieldByName = (name: string | null | undefined): boolean => !!name && NUMBER_NAME_PATTERN.test(name);
|
||||
|
||||
const isEmailFieldByName = (name: string | null | undefined): boolean => !!name && EMAIL_NAME_PATTERN.test(name);
|
||||
|
||||
const isNameFieldByName = (name: string | null | undefined): boolean => !!name && NAME_NAME_PATTERN.test(name);
|
||||
|
||||
const isInitialsFieldByName = (name: string | null | undefined): boolean => !!name && INITIALS_NAME_PATTERN.test(name);
|
||||
|
||||
/**
|
||||
* Detect AcroForm format actions on a text field dictionary.
|
||||
*
|
||||
* Adobe attaches a JavaScript format action via `/AA` → `/F` → `/JS`. The script
|
||||
* body references `AFDate_FormatEx` or `AFNumber_Format` depending on the
|
||||
* intended format. The action dict and its `/F` entry are frequently stored
|
||||
* as indirect refs in real-world PDFs, so a {@link RefResolver} MUST be
|
||||
* threaded through the lookups. We do a string-contains check on the script
|
||||
* to avoid pulling in a JS parser.
|
||||
*/
|
||||
const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => {
|
||||
try {
|
||||
const formatDict = fieldDict.getDict('AA', resolver)?.getDict('F', resolver);
|
||||
|
||||
if (!formatDict) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const js = formatDict.get('JS', resolver);
|
||||
|
||||
if (!js || typeof js !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let script: string;
|
||||
|
||||
if (js.type === 'string') {
|
||||
script = (js as PdfString).asString();
|
||||
} else if (js.type === 'stream') {
|
||||
script = new TextDecoder().decode((js as PdfStream).getDecodedData());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
if (script.includes('AFNumber_Format')) {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type FormFieldWithDict = {
|
||||
name: string;
|
||||
partialName: string;
|
||||
alternateName: string | null;
|
||||
isReadOnly(): boolean;
|
||||
isRequired(): boolean;
|
||||
acroField(): PdfDict;
|
||||
getWidgets(): WidgetAnnotation[];
|
||||
};
|
||||
|
||||
type ResolvedTextDocumensoType =
|
||||
| typeof FieldType.TEXT
|
||||
| typeof FieldType.DATE
|
||||
| typeof FieldType.NUMBER
|
||||
| typeof FieldType.EMAIL
|
||||
| typeof FieldType.NAME
|
||||
| typeof FieldType.INITIALS;
|
||||
|
||||
const resolveTextSubtype = (
|
||||
field: FormFieldWithDict,
|
||||
resolver: RefResolver,
|
||||
): {
|
||||
documensoType: ResolvedTextDocumensoType;
|
||||
} => {
|
||||
const candidateNames = [field.partialName, field.alternateName];
|
||||
|
||||
const formatHint = getTextFieldFormatHint(field.acroField(), resolver);
|
||||
|
||||
// AcroForm format actions take precedence over name tokens — Adobe set them
|
||||
// explicitly, so they're a stronger signal than a heuristic regex hit.
|
||||
if (formatHint === 'date') {
|
||||
return { documensoType: FieldType.DATE };
|
||||
}
|
||||
|
||||
if (formatHint === 'number') {
|
||||
return { documensoType: FieldType.NUMBER };
|
||||
}
|
||||
|
||||
const maxLen = field.acroField().getNumber('MaxLen', resolver)?.value ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (candidateNames.some(isDateFieldByName)) {
|
||||
return { documensoType: FieldType.DATE };
|
||||
}
|
||||
|
||||
if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) {
|
||||
return { documensoType: FieldType.NUMBER };
|
||||
}
|
||||
|
||||
if (candidateNames.some(isEmailFieldByName)) {
|
||||
return { documensoType: FieldType.EMAIL };
|
||||
}
|
||||
|
||||
if (candidateNames.some(isNameFieldByName)) {
|
||||
return { documensoType: FieldType.NAME };
|
||||
}
|
||||
|
||||
if (candidateNames.some(isInitialsFieldByName)) {
|
||||
return { documensoType: FieldType.INITIALS };
|
||||
}
|
||||
|
||||
return { documensoType: FieldType.TEXT };
|
||||
};
|
||||
|
||||
const pickLabel = (field: FormFieldWithDict): string | undefined => {
|
||||
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
|
||||
return field.alternateName || undefined;
|
||||
};
|
||||
|
||||
type RotationDegrees = 0 | 90 | 180 | 270;
|
||||
|
||||
type RawRect = { x1: number; y1: number; x2: number; y2: number };
|
||||
|
||||
const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => {
|
||||
const rect = widget.rect;
|
||||
|
||||
if (!rect || rect.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = rect;
|
||||
|
||||
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
};
|
||||
|
||||
const resolveGeometry = (
|
||||
widget: WidgetAnnotation,
|
||||
pageIndex: number,
|
||||
page: PDFPage,
|
||||
): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => {
|
||||
const rect = getRectFromWidget(widget);
|
||||
|
||||
if (!rect) {
|
||||
return { geometry: null, reason: 'zero-size' };
|
||||
}
|
||||
|
||||
const xL = Math.min(rect.x1, rect.x2);
|
||||
const xR = Math.max(rect.x1, rect.x2);
|
||||
const yB = Math.min(rect.y1, rect.y2);
|
||||
const yT = Math.max(rect.y1, rect.y2);
|
||||
|
||||
if (xR - xL <= 0 || yT - yB <= 0) {
|
||||
return { geometry: null, reason: 'zero-size' };
|
||||
}
|
||||
|
||||
const mediaBox = page.getMediaBox();
|
||||
const mediaW = mediaBox.width;
|
||||
const mediaH = mediaBox.height;
|
||||
const rotation = page.rotation as RotationDegrees;
|
||||
|
||||
// PDFPage.width / .height return rotation-adjusted dimensions, which is what
|
||||
// we want for percent-based positioning relative to the rendered page.
|
||||
const renderedW = page.width;
|
||||
const renderedH = page.height;
|
||||
|
||||
let renderedX: number;
|
||||
let renderedY: number;
|
||||
let renderedFieldW: number;
|
||||
let renderedFieldH: number;
|
||||
|
||||
if (rotation === 90) {
|
||||
renderedX = yB;
|
||||
renderedY = xL;
|
||||
renderedFieldW = yT - yB;
|
||||
renderedFieldH = xR - xL;
|
||||
} else if (rotation === 180) {
|
||||
renderedX = mediaW - xR;
|
||||
renderedY = yB;
|
||||
renderedFieldW = xR - xL;
|
||||
renderedFieldH = yT - yB;
|
||||
} else if (rotation === 270) {
|
||||
renderedX = mediaH - yT;
|
||||
renderedY = mediaW - xR;
|
||||
renderedFieldW = yT - yB;
|
||||
renderedFieldH = xR - xL;
|
||||
} else {
|
||||
renderedX = xL;
|
||||
renderedY = mediaH - yT;
|
||||
renderedFieldW = xR - xL;
|
||||
renderedFieldH = yT - yB;
|
||||
}
|
||||
|
||||
// Out-of-bounds: skip if the entire rect is outside the rendered page bounds.
|
||||
const left = renderedX;
|
||||
const right = renderedX + renderedFieldW;
|
||||
const top = renderedY;
|
||||
const bottom = renderedY + renderedFieldH;
|
||||
|
||||
if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) {
|
||||
return { geometry: null, reason: 'off-page' };
|
||||
}
|
||||
|
||||
// Partial out-of-bounds: clamp.
|
||||
const clampedLeft = Math.max(0, Math.min(left, renderedW));
|
||||
const clampedRight = Math.max(0, Math.min(right, renderedW));
|
||||
const clampedTop = Math.max(0, Math.min(top, renderedH));
|
||||
const clampedBottom = Math.max(0, Math.min(bottom, renderedH));
|
||||
|
||||
const clampedW = clampedRight - clampedLeft;
|
||||
const clampedH = clampedBottom - clampedTop;
|
||||
|
||||
if (clampedW <= 0 || clampedH <= 0) {
|
||||
return { geometry: null, reason: 'off-page' };
|
||||
}
|
||||
|
||||
return {
|
||||
geometry: {
|
||||
page: pageIndex + 1,
|
||||
x: clampedLeft,
|
||||
y: clampedTop,
|
||||
width: clampedW,
|
||||
height: clampedH,
|
||||
pageWidth: renderedW,
|
||||
pageHeight: renderedH,
|
||||
},
|
||||
reason: null,
|
||||
};
|
||||
};
|
||||
|
||||
const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => {
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
...FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
required: field.isRequired() || undefined,
|
||||
readOnly: field.isReadOnly() || undefined,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const buildTextFieldAndMeta = (
|
||||
field: FormFieldWithDict,
|
||||
documensoType: ResolvedTextDocumensoType,
|
||||
defaultText: string | undefined,
|
||||
): TFieldAndMeta => {
|
||||
const label = pickLabel(field);
|
||||
const required = field.isRequired() || undefined;
|
||||
const readOnly = field.isReadOnly() || undefined;
|
||||
|
||||
const defaultValue = defaultText && defaultText.length > 0 ? defaultText : undefined;
|
||||
if (documensoType === FieldType.NUMBER) {
|
||||
const fieldMeta: TNumberFieldMeta = {
|
||||
...FIELD_NUMBER_META_DEFAULT_VALUES,
|
||||
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
value: defaultValue,
|
||||
};
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
|
||||
}
|
||||
|
||||
if (documensoType === FieldType.TEXT) {
|
||||
const fieldMeta: TTextFieldMeta = {
|
||||
...FIELD_TEXT_META_DEFAULT_VALUES,
|
||||
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
text: defaultValue ?? FIELD_TEXT_META_DEFAULT_VALUES.text,
|
||||
};
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
|
||||
}
|
||||
|
||||
if (documensoType === FieldType.DATE) {
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: documensoType,
|
||||
fieldMeta: {
|
||||
...FIELD_DATE_META_DEFAULT_VALUES,
|
||||
label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (documensoType === FieldType.EMAIL) {
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: documensoType,
|
||||
fieldMeta: {
|
||||
...FIELD_EMAIL_META_DEFAULT_VALUES,
|
||||
label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (documensoType === FieldType.NAME) {
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: documensoType,
|
||||
fieldMeta: {
|
||||
...FIELD_NAME_META_DEFAULT_VALUES,
|
||||
label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||
type: documensoType,
|
||||
fieldMeta: {
|
||||
...FIELD_INITIALS_META_DEFAULT_VALUES,
|
||||
label,
|
||||
required,
|
||||
readOnly,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const buildCheckboxFieldAndMeta = (
|
||||
field: FormFieldWithDict,
|
||||
onValue: string | undefined,
|
||||
isChecked: boolean,
|
||||
): TFieldAndMeta => {
|
||||
const required = field.isRequired();
|
||||
const value = onValue && onValue.length > 0 ? onValue : 'Yes';
|
||||
|
||||
const fieldMeta: TCheckboxFieldMeta = {
|
||||
...FIELD_CHECKBOX_META_DEFAULT_VALUES,
|
||||
label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label,
|
||||
required: required || undefined,
|
||||
readOnly: field.isReadOnly() || undefined,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
values: [{ id: 1, checked: isChecked, value }],
|
||||
validationRule: required ? 'at-least' : '',
|
||||
validationLength: required ? 1 : 0,
|
||||
};
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta });
|
||||
};
|
||||
|
||||
const buildRadioFieldAndMeta = (
|
||||
field: FormFieldWithDict,
|
||||
options: string[],
|
||||
selectedValue: string | null,
|
||||
widgetOnValue: string | null,
|
||||
): TFieldAndMeta => {
|
||||
const values =
|
||||
options.length > 0
|
||||
? options.map((value, index) => ({
|
||||
id: index + 1,
|
||||
checked: selectedValue !== null && value === selectedValue,
|
||||
value,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: 1,
|
||||
checked: widgetOnValue !== null && widgetOnValue === selectedValue,
|
||||
value: widgetOnValue ?? '',
|
||||
},
|
||||
];
|
||||
|
||||
const fieldMeta: TRadioFieldMeta = {
|
||||
...FIELD_RADIO_META_DEFAULT_VALUES,
|
||||
label: pickLabel(field) ?? '',
|
||||
required: field.isRequired() || undefined,
|
||||
readOnly: field.isReadOnly() || undefined,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
values,
|
||||
};
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta });
|
||||
};
|
||||
|
||||
const buildDropdownFieldAndMeta = (
|
||||
field: FormFieldWithDict,
|
||||
options: string[],
|
||||
defaultValue: string | undefined,
|
||||
): TFieldAndMeta => {
|
||||
const fieldMeta: TDropdownFieldMeta = {
|
||||
...FIELD_DROPDOWN_META_DEFAULT_VALUES,
|
||||
label: pickLabel(field) ?? '',
|
||||
required: field.isRequired() || undefined,
|
||||
readOnly: field.isReadOnly() || undefined,
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values,
|
||||
defaultValue: defaultValue ?? '',
|
||||
};
|
||||
|
||||
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta });
|
||||
};
|
||||
|
||||
type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage };
|
||||
|
||||
const resolveWidgetPages = (
|
||||
widgets: WidgetAnnotation[],
|
||||
pageByRef: Map<PdfRef, { index: number; page: PDFPage }>,
|
||||
): {
|
||||
matched: WidgetWithPage[];
|
||||
unmatched: WidgetAnnotation[];
|
||||
} => {
|
||||
const matched: WidgetWithPage[] = [];
|
||||
const unmatched: WidgetAnnotation[] = [];
|
||||
|
||||
for (const widget of widgets) {
|
||||
const pageRef = widget.pageRef;
|
||||
const resolved = pageRef ? pageByRef.get(pageRef) : null;
|
||||
|
||||
if (!resolved) {
|
||||
unmatched.push(widget);
|
||||
continue;
|
||||
}
|
||||
|
||||
matched.push({ widget, pageIndex: resolved.index, page: resolved.page });
|
||||
}
|
||||
|
||||
return { matched, unmatched };
|
||||
};
|
||||
|
||||
export type ExtractAcroFormOptions = {
|
||||
/**
|
||||
* When true, `insertFormValuesInPdf` already ran for this buffer. The
|
||||
* extractor will not copy AcroForm default values into `fieldMeta` to
|
||||
* avoid duplicating values that are already baked into the flattened PDF.
|
||||
*/
|
||||
formValuesProvided?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract AcroForm fields from a PDF and convert them to Documenso field
|
||||
* imports.
|
||||
*
|
||||
* Runs before flattening so widget geometry is still present in the buffer.
|
||||
* Returns an empty result for non-AcroForm PDFs, encrypted PDFs, pure XFA forms
|
||||
* with no AcroForm widgets, and on any internal error (with `skipReason` set so
|
||||
* callers can log).
|
||||
*/
|
||||
export const extractAcroFormFieldsFromPDF = async (
|
||||
pdf: Buffer,
|
||||
options: ExtractAcroFormOptions = {},
|
||||
): Promise<AcroFormExtractionResult> => {
|
||||
try {
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||
|
||||
if (pdfDoc.isEncrypted) {
|
||||
return EMPTY_RESULT('encrypted');
|
||||
}
|
||||
|
||||
const resolver = makeResolver(pdfDoc);
|
||||
|
||||
const hasXfaForm = hasXfa(pdfDoc, resolver);
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
if (!form) {
|
||||
return EMPTY_RESULT(hasXfaForm ? 'xfa-hybrid' : 'no-form');
|
||||
}
|
||||
|
||||
const formFields = form.getFields();
|
||||
|
||||
if (hasXfaForm && formFields.length === 0) {
|
||||
return EMPTY_RESULT('xfa-hybrid');
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
pageByRef.set(page.ref, { index, page });
|
||||
});
|
||||
|
||||
const fields: AcroFormFieldImportInfo[] = [];
|
||||
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
|
||||
let hasSignedSignature = false;
|
||||
const usePdfDefaults = !options.formValuesProvided;
|
||||
const addUnsupported = (fieldName: string, acroFormType: string, reason: AcroFormUnsupportedReason): void => {
|
||||
unsupported.push({ fieldName, acroFormType, reason });
|
||||
};
|
||||
|
||||
for (const field of formFields) {
|
||||
const acroFormType = field.type;
|
||||
|
||||
if (
|
||||
acroFormType === 'listbox' ||
|
||||
acroFormType === 'button' ||
|
||||
acroFormType === 'unknown' ||
|
||||
acroFormType === 'non-terminal'
|
||||
) {
|
||||
addUnsupported(field.name, acroFormType, 'unsupported-type');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Signed signature widgets are skipped entirely and the caller is asked
|
||||
// to keep the form intact (no flatten) so the signature stays valid.
|
||||
if (acroFormType === 'signature') {
|
||||
type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean };
|
||||
const sigField = field as unknown as SignatureFieldDuck;
|
||||
|
||||
if (typeof sigField.isSigned === 'function' && sigField.isSigned()) {
|
||||
hasSignedSignature = true;
|
||||
addUnsupported(field.name, acroFormType, 'signed-signature');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const formField = field as unknown as FormFieldWithDict;
|
||||
const widgets = formField.getWidgets();
|
||||
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
|
||||
|
||||
for (let i = 0; i < unmatched.length; i += 1) {
|
||||
addUnsupported(field.name, acroFormType, 'no-page-match');
|
||||
}
|
||||
|
||||
let widgetCounter = 0;
|
||||
|
||||
for (const { widget, pageIndex, page } of matched) {
|
||||
if (widget.isHidden()) {
|
||||
addUnsupported(field.name, acroFormType, 'hidden');
|
||||
continue;
|
||||
}
|
||||
|
||||
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
|
||||
|
||||
if (!geometry) {
|
||||
addUnsupported(field.name, acroFormType, reason ?? 'zero-size');
|
||||
continue;
|
||||
}
|
||||
|
||||
let fieldAndMeta: TFieldAndMeta;
|
||||
|
||||
if (acroFormType === 'signature') {
|
||||
fieldAndMeta = buildSignatureFieldAndMeta(formField);
|
||||
} else if (acroFormType === 'text') {
|
||||
type TextFieldDuck = FormFieldWithDict & {
|
||||
getValue(): string;
|
||||
getDefaultValue(): string;
|
||||
};
|
||||
const textField = field as unknown as TextFieldDuck;
|
||||
const { documensoType } = resolveTextSubtype(formField, resolver);
|
||||
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
|
||||
fieldAndMeta = buildTextFieldAndMeta(formField, documensoType, defaultText);
|
||||
} else if (acroFormType === 'checkbox') {
|
||||
type CheckboxFieldDuck = FormFieldWithDict & {
|
||||
isChecked(): boolean;
|
||||
getOnValue(): string;
|
||||
};
|
||||
const checkbox = field as unknown as CheckboxFieldDuck;
|
||||
const onValue = widget.getOnValue() ?? checkbox.getOnValue?.();
|
||||
const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false;
|
||||
fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked);
|
||||
} else if (acroFormType === 'radio') {
|
||||
type RadioFieldDuck = FormFieldWithDict & {
|
||||
getOptions(): string[];
|
||||
getValue(): string | null;
|
||||
};
|
||||
const radio = field as unknown as RadioFieldDuck;
|
||||
const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null;
|
||||
fieldAndMeta = buildRadioFieldAndMeta(
|
||||
formField,
|
||||
radio.getOptions?.() ?? [],
|
||||
selectedValue,
|
||||
widget.getOnValue(),
|
||||
);
|
||||
} else if (acroFormType === 'dropdown') {
|
||||
type DropdownFieldDuck = FormFieldWithDict & {
|
||||
getOptions(): Array<{ value: string; display: string }>;
|
||||
getValue(): string;
|
||||
getDefaultValue(): string;
|
||||
};
|
||||
const dropdown = field as unknown as DropdownFieldDuck;
|
||||
const rawOptions = dropdown.getOptions?.() ?? [];
|
||||
const optionValues = rawOptions.map((opt) => opt.value);
|
||||
const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : '';
|
||||
fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined);
|
||||
} else {
|
||||
addUnsupported(field.name, acroFormType, 'unsupported-type');
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
source: ACROFORM_FIELD_SOURCE,
|
||||
fieldName: field.name,
|
||||
widgetIndex: widgetCounter,
|
||||
fieldAndMeta,
|
||||
page: geometry.page,
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
width: geometry.width,
|
||||
height: geometry.height,
|
||||
pageWidth: geometry.pageWidth,
|
||||
pageHeight: geometry.pageHeight,
|
||||
});
|
||||
|
||||
widgetCounter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
unsupported,
|
||||
hasSignedSignature,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw');
|
||||
|
||||
return EMPTY_RESULT('error');
|
||||
}
|
||||
};
|
||||
|
||||
const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => {
|
||||
return [...fields].sort((a, b) => {
|
||||
if (a.page !== b.page) {
|
||||
return a.page - b.page;
|
||||
}
|
||||
|
||||
const aRowPercent = (a.y / a.pageHeight) * 100;
|
||||
const bRowPercent = (b.y / b.pageHeight) * 100;
|
||||
|
||||
if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) {
|
||||
return aRowPercent - bRowPercent;
|
||||
}
|
||||
|
||||
return a.x - b.x;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert pre-extracted AcroForm fields to field creation inputs.
|
||||
*
|
||||
* Pure data transform — converts points to percentages and resolves the
|
||||
* recipient via the provided callback. No DB calls.
|
||||
*/
|
||||
export const convertAcroFormFieldsToFieldInputs = (
|
||||
fields: AcroFormFieldImportInfo[],
|
||||
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
|
||||
envelopeItemId?: string,
|
||||
): FieldToCreate[] => {
|
||||
return sortFieldsForCreate(fields).map((f) => {
|
||||
const xPercent = (f.x / f.pageWidth) * 100;
|
||||
const yPercent = (f.y / f.pageHeight) * 100;
|
||||
const widthPercent = (f.width / f.pageWidth) * 100;
|
||||
const heightPercent = (f.height / f.pageHeight) * 100;
|
||||
|
||||
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
||||
|
||||
const recipient = recipientResolver(f.fieldName);
|
||||
|
||||
return {
|
||||
...f.fieldAndMeta,
|
||||
envelopeItemId,
|
||||
recipientId: recipient.id,
|
||||
page: f.page,
|
||||
positionX: xPercent,
|
||||
positionY: yPercent,
|
||||
width: widthPercent,
|
||||
height: finalHeightPercent,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FontLibrary } from '@documenso/skia-canvas';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import Konva from 'konva';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { ensureFontLibrary } from './helpers';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// sort-imports-ignore
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import { Image as SkiaImage } from '@documenso/skia-canvas';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// sort-imports-ignore
|
||||
import '../konva/skia-backend';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from '@documenso/skia-canvas';
|
||||
import { Image as SkiaImage } from '@documenso/skia-canvas';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Field, RecipientRole, Signature } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { Image as SkiaImage } from 'skia-canvas';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export const ZBaseFieldMeta = z.object({
|
||||
readOnly: z.boolean().optional(),
|
||||
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
|
||||
overflow: ZFieldOverflowMode.optional(),
|
||||
source: z.enum(['acroform']).optional(),
|
||||
});
|
||||
|
||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||
|
||||
@@ -14,7 +14,7 @@ let SkiaImage: any;
|
||||
|
||||
void (async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
const mod = await import('skia-canvas');
|
||||
const mod = await import('@documenso/skia-canvas');
|
||||
SkiaImage = mod.Image;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@trpc/client": "11.8.1",
|
||||
"@trpc/react-query": "11.8.1",
|
||||
"@trpc/server": "11.8.1",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"formidable": "^3.5.4",
|
||||
"luxon": "^3.7.2",
|
||||
|
||||
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/cancel',
|
||||
summary: 'Cancel envelope',
|
||||
description: 'Cancel a pending envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCancelEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
|
||||
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
|
||||
});
|
||||
|
||||
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -6,9 +6,7 @@ import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-plac
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { Logger } from 'pino';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -121,7 +119,7 @@ export const createEnvelopeRouteCaller = async ({
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(pdf, {
|
||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
||||
flattenForm: false,
|
||||
});
|
||||
|
||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||
@@ -149,11 +147,19 @@ export const createEnvelopeRouteCaller = async ({
|
||||
accessAuth: recipient.accessAuth,
|
||||
actionAuth: recipient.actionAuth,
|
||||
fields: recipient.fields?.map((field) => {
|
||||
const documentDataId = match(field.identifier)
|
||||
.with(P.string, (title) => envelopeItems.find((item) => item.title === title)?.documentDataId)
|
||||
.with(P.number, (index) => envelopeItems.at(index)?.documentDataId)
|
||||
.with(undefined, () => envelopeItems.at(0)?.documentDataId)
|
||||
.exhaustive();
|
||||
let documentDataId: string | undefined;
|
||||
|
||||
if (typeof field.identifier === 'string') {
|
||||
documentDataId = envelopeItems.find((item) => item.title === field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof field.identifier === 'number') {
|
||||
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (field.identifier === undefined) {
|
||||
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||
}
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
|
||||
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/delete',
|
||||
summary: 'Delete envelope',
|
||||
description: 'Delete an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDeleteEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to delete.'),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZImportAcroFormFieldsRequestSchema,
|
||||
ZImportAcroFormFieldsResponseSchema,
|
||||
} from './import-acroform-fields.types';
|
||||
|
||||
export const importAcroFormFieldsRoute = authenticatedProcedure
|
||||
.input(ZImportAcroFormFieldsRequestSchema)
|
||||
.output(ZImportAcroFormFieldsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId, metadata } = ctx;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({ input: { envelopeId } });
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: { documentData: true },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.internalVersion !== 2) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AcroForm import is only supported for version 2 envelopes',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AcroForm import is only allowed while the envelope is in draft',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope has no items to import from',
|
||||
});
|
||||
}
|
||||
|
||||
return UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||
envelope,
|
||||
apiRequestMetadata: metadata,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZImportAcroFormFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZImportAcroFormFieldsResponseSchema = z.object({
|
||||
itemsProcessed: z.number().int().min(0),
|
||||
fieldsCreated: z.number().int().min(0),
|
||||
unsupportedCount: z.number().int().min(0),
|
||||
signedSignatureCount: z.number().int().min(0),
|
||||
skippedItems: z.array(
|
||||
z.object({
|
||||
envelopeItemId: z.string(),
|
||||
envelopeItemTitle: z.string(),
|
||||
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
|
||||
}),
|
||||
),
|
||||
fields: z.array(ZEnvelopeFieldSchema),
|
||||
});
|
||||
|
||||
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
|
||||
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
|
||||
@@ -34,6 +34,7 @@ import { getEnvelopeRoute } from './get-envelope';
|
||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
|
||||
import { importAcroFormFieldsRoute } from './import-acroform-fields';
|
||||
import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
||||
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
|
||||
import { saveAsTemplateRoute } from './save-as-template';
|
||||
@@ -83,6 +84,7 @@ export const envelopeRouter = router({
|
||||
delete: deleteEnvelopeFieldRoute,
|
||||
set: setEnvelopeFieldsRoute,
|
||||
sign: signEnvelopeFieldRoute,
|
||||
importFromPdf: importAcroFormFieldsRoute,
|
||||
},
|
||||
find: findEnvelopesRoute,
|
||||
auditLog: {
|
||||
|
||||
@@ -39,8 +39,8 @@ export const signEnvelopeFieldRoute = procedure
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient:
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
recipient: {
|
||||
...(recipient.role === RecipientRole.ASSISTANT
|
||||
? {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
@@ -52,6 +52,7 @@ export const signEnvelopeFieldRoute = procedure
|
||||
}
|
||||
: {
|
||||
id: recipient.id,
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/update',
|
||||
summary: 'Update envelope',
|
||||
description: 'Update envelope properties and settings',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZUpdateEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to update.'),
|
||||
data: z
|
||||
.object({
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
folderId: z.string().nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
})
|
||||
globalAccessAuth: z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.describe('The authentication methods required to access the envelope.')
|
||||
.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||
globalActionAuth: z
|
||||
.array(ZDocumentActionAuthTypesSchema)
|
||||
.describe('The authentication methods required to sign the envelope.')
|
||||
.optional(),
|
||||
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
|
||||
})
|
||||
.describe('The envelope properties to update.')
|
||||
.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
|
||||
@@ -88,11 +87,20 @@ export const useEnvelopeRoute = authenticatedProcedure
|
||||
|
||||
// Map custom document data using identifiers
|
||||
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
|
||||
let documentDataId: string | undefined;
|
||||
|
||||
// Find the uploaded file by identifier
|
||||
const documentDataId = match(mapping.identifier)
|
||||
.with(P.string, (name) => uploadedFiles.find((file) => file.name === name)?.documentDataId)
|
||||
.with(P.number, (index) => uploadedFiles.at(index)?.documentDataId)
|
||||
.exhaustive();
|
||||
if (typeof mapping.identifier === 'string') {
|
||||
documentDataId = uploadedFiles.find((file) => file.name === mapping.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof mapping.identifier === 'number') {
|
||||
documentDataId = uploadedFiles.at(mapping.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (mapping.identifier === undefined) {
|
||||
documentDataId = uploadedFiles.at(0)?.documentDataId;
|
||||
}
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
|
||||
@@ -603,7 +603,7 @@ export const recipientRouter = router({
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
@@ -611,6 +611,16 @@ export const recipientRouter = router({
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
|
||||
// The most common cause is a stale signing page: the document was
|
||||
// deleted, or the recipient was removed, after the link was opened.
|
||||
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found for the provided signing token',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
|
||||
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CheckSquareIcon,
|
||||
ChevronDownIcon,
|
||||
ContactIcon,
|
||||
DiscIcon,
|
||||
EyeOffIcon,
|
||||
HashIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
TypeIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ElementType, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
|
||||
import { extractInitials } from '../../../lib/utils/recipient-formatter';
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Avatar, AvatarFallback } from '../../primitives/avatar';
|
||||
import { Badge } from '../../primitives/badge';
|
||||
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
|
||||
import { PopoverHover } from '../../primitives/popover';
|
||||
|
||||
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
|
||||
showRecipientColors?: boolean;
|
||||
}
|
||||
|
||||
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
|
||||
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return recipient.name;
|
||||
}
|
||||
|
||||
return recipient.email;
|
||||
const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
|
||||
[FieldType.SIGNATURE]: SignatureIcon,
|
||||
[FieldType.FREE_SIGNATURE]: SignatureIcon,
|
||||
[FieldType.INITIALS]: ContactIcon,
|
||||
[FieldType.TEXT]: TypeIcon,
|
||||
[FieldType.DATE]: CalendarDaysIcon,
|
||||
[FieldType.EMAIL]: MailIcon,
|
||||
[FieldType.NAME]: UserIcon,
|
||||
[FieldType.NUMBER]: HashIcon,
|
||||
[FieldType.RADIO]: DiscIcon,
|
||||
[FieldType.CHECKBOX]: CheckSquareIcon,
|
||||
[FieldType.DROPDOWN]: ChevronDownIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
}: EnvelopeRecipientFieldTooltipProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const FieldIcon = FIELD_TYPE_ICONS[field.type];
|
||||
|
||||
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
|
||||
|
||||
const [coords, setCoords] = useState({
|
||||
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
|
||||
className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
|
||||
sideOffset: 20,
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<p className="min-w-0 flex-1 truncate font-medium">
|
||||
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
|
||||
</p>
|
||||
|
||||
{showFieldStatus && (
|
||||
<Badge
|
||||
className="mx-auto mb-1 py-0.5"
|
||||
variant={
|
||||
field?.fieldMeta?.readOnly
|
||||
? 'neutral'
|
||||
: field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||
{field?.fieldMeta?.readOnly ? (
|
||||
<>
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<LockIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Read Only</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
<Trans>Signed</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClockIcon className="mr-1 h-3 w-3" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
<Trans>Pending</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center font-semibold">
|
||||
<span>
|
||||
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
|
||||
|
||||
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
|
||||
{!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
|
||||
<p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
||||
type="button"
|
||||
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
onClick={() => setHideField(true)}
|
||||
title="Hide field"
|
||||
title={t`Hide field`}
|
||||
>
|
||||
<EyeOffIcon className="h-3 w-3" />
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const SigningCard3D = ({ className, name, signature, signingCelebrationIm
|
||||
|
||||
const [trackMouse, setTrackMouse] = useState(false);
|
||||
|
||||
const timeoutRef = useRef<number | undefined>();
|
||||
const timeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
|
||||
@@ -18,59 +18,32 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"react": "^18",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"colord": "^2.9.3",
|
||||
"framer-motion": "^12.43.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
|
||||
@@ -145,7 +145,9 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
// cmdk 1.x always renders data-disabled="true|false", so the variant must
|
||||
// check the value (bare data-[disabled] matches attribute presence).
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -103,15 +103,10 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 py-0.5">
|
||||
<RadioGroup className="gap-y-1">
|
||||
<RadioGroup value={field.customText ?? ''} className="gap-y-1">
|
||||
{field.fieldMeta.values.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="pointer-events-none h-3 w-3"
|
||||
value={item.value}
|
||||
id={`option-${index}`}
|
||||
checked={item.value === field.customText}
|
||||
/>
|
||||
<RadioGroupItem className="pointer-events-none h-3 w-3" value={item.value} id={`option-${index}`} />
|
||||
{item.value && (
|
||||
<Label htmlFor={`option-${index}`} className="ml-1.5 font-normal text-foreground text-xs">
|
||||
{item.value}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RecipientRole } from '@prisma/client';
|
||||
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
import { SignaturePadColorPicker } from './signature-pad-color-picker';
|
||||
|
||||
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement>) => {
|
||||
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement | null>) => {
|
||||
if (!element.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { generateId } from './utils/generate-id';
|
||||
|
||||
const JUSTIFICATIONS_DIR = join(process.cwd(), '.agents', 'justifications');
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: npx tsx scripts/create-justification.ts "file-slug" [content]');
|
||||
console.error(' or: npx tsx scripts/create-justification.ts "file-slug" << HEREDOC');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = args[0];
|
||||
let content = '';
|
||||
|
||||
// Check if content is provided as second argument
|
||||
if (args.length > 1) {
|
||||
content = args.slice(1).join(' ');
|
||||
} else {
|
||||
// Read from stdin (heredoc)
|
||||
try {
|
||||
const stdin = readFileSync(0, 'utf-8');
|
||||
content = stdin.trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading from stdin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
console.error('Error: No content provided');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
const id = generateId();
|
||||
const filename = `${id}-${slug}.md`;
|
||||
const filepath = join(JUSTIFICATIONS_DIR, filename);
|
||||
|
||||
// Format title from slug (kebab-case to Title Case)
|
||||
const title = slug
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Get current date in ISO format
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create frontmatter
|
||||
const frontmatter = `---
|
||||
date: ${date}
|
||||
title: ${title}
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(JUSTIFICATIONS_DIR, { recursive: true });
|
||||
|
||||
// Write file with frontmatter
|
||||
writeFileSync(filepath, frontmatter + content, 'utf-8');
|
||||
|
||||
console.log(`Created justification: ${filepath}`);
|
||||
console.log(`ID: ${id}`);
|
||||
console.log(`Filename: ${filename}`);
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { generateId } from './utils/generate-id';
|
||||
|
||||
const PLANS_DIR = join(process.cwd(), '.agents', 'plans');
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: npx tsx scripts/create-plan.ts "file-slug" [content]');
|
||||
console.error(' or: npx tsx scripts/create-plan.ts "file-slug" << HEREDOC');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = args[0];
|
||||
let content = '';
|
||||
|
||||
// Check if content is provided as second argument
|
||||
if (args.length > 1) {
|
||||
content = args.slice(1).join(' ');
|
||||
} else {
|
||||
// Read from stdin (heredoc)
|
||||
try {
|
||||
const stdin = readFileSync(0, 'utf-8');
|
||||
content = stdin.trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading from stdin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
console.error('Error: No content provided');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
const id = generateId();
|
||||
const filename = `${id}-${slug}.md`;
|
||||
const filepath = join(PLANS_DIR, filename);
|
||||
|
||||
// Format title from slug (kebab-case to Title Case)
|
||||
const title = slug
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Get current date in ISO format
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create frontmatter
|
||||
const frontmatter = `---
|
||||
date: ${date}
|
||||
title: ${title}
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(PLANS_DIR, { recursive: true });
|
||||
|
||||
// Write file with frontmatter
|
||||
writeFileSync(filepath, frontmatter + content, 'utf-8');
|
||||
|
||||
console.log(`Created plan: ${filepath}`);
|
||||
console.log(`ID: ${id}`);
|
||||
console.log(`Filename: ${filename}`);
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { generateId } from './utils/generate-id';
|
||||
|
||||
const SCRATCHES_DIR = join(process.cwd(), '.agents', 'scratches');
|
||||
|
||||
const main = () => {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: npx tsx scripts/create-scratch.ts "file-slug" [content]');
|
||||
console.error(' or: npx tsx scripts/create-scratch.ts "file-slug" << HEREDOC');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = args[0];
|
||||
let content = '';
|
||||
|
||||
// Check if content is provided as second argument
|
||||
if (args.length > 1) {
|
||||
content = args.slice(1).join(' ');
|
||||
} else {
|
||||
// Read from stdin (heredoc)
|
||||
try {
|
||||
const stdin = readFileSync(0, 'utf-8');
|
||||
content = stdin.trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading from stdin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
console.error('Error: No content provided');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
const id = generateId();
|
||||
const filename = `${id}-${slug}.md`;
|
||||
const filepath = join(SCRATCHES_DIR, filename);
|
||||
|
||||
// Format title from slug (kebab-case to Title Case)
|
||||
const title = slug
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
// Get current date in ISO format
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Create frontmatter
|
||||
const frontmatter = `---
|
||||
date: ${date}
|
||||
title: ${title}
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(SCRATCHES_DIR, { recursive: true });
|
||||
|
||||
// Write file with frontmatter
|
||||
writeFileSync(filepath, frontmatter + content, 'utf-8');
|
||||
|
||||
console.log(`Created scratch: ${filepath}`);
|
||||
console.log(`ID: ${id}`);
|
||||
console.log(`Filename: ${filename}`);
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Generates a unique identifier using three simple words.
|
||||
* Falls back to unix timestamp if word generation fails.
|
||||
*/
|
||||
export const generateId = (): string => {
|
||||
const adjectives = [
|
||||
'happy',
|
||||
'bright',
|
||||
'swift',
|
||||
'calm',
|
||||
'bold',
|
||||
'clever',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'warm',
|
||||
'cool',
|
||||
'fresh',
|
||||
'solid',
|
||||
'clear',
|
||||
'sweet',
|
||||
'wild',
|
||||
'quiet',
|
||||
'loud',
|
||||
'smooth',
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'moon',
|
||||
'star',
|
||||
'ocean',
|
||||
'river',
|
||||
'forest',
|
||||
'mountain',
|
||||
'cloud',
|
||||
'wave',
|
||||
'stone',
|
||||
'flower',
|
||||
'bird',
|
||||
'wind',
|
||||
'light',
|
||||
'shadow',
|
||||
'fire',
|
||||
'earth',
|
||||
'sky',
|
||||
'tree',
|
||||
'leaf',
|
||||
'rock',
|
||||
];
|
||||
|
||||
const colors = [
|
||||
'blue',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'purple',
|
||||
'orange',
|
||||
'pink',
|
||||
'cyan',
|
||||
'amber',
|
||||
'emerald',
|
||||
'violet',
|
||||
'indigo',
|
||||
'coral',
|
||||
'teal',
|
||||
'gold',
|
||||
'silver',
|
||||
'copper',
|
||||
'bronze',
|
||||
'ivory',
|
||||
'jade',
|
||||
];
|
||||
|
||||
try {
|
||||
const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
|
||||
return `${randomAdjective}-${randomColor}-${randomNoun}`;
|
||||
} catch {
|
||||
// Fallback to unix timestamp if something goes wrong
|
||||
return Date.now().toString();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user