diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index fa19bb6a1..5b9322a79 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -142,7 +142,7 @@ export const EnvelopeEditorUploadPage = () => { const { createdEnvelopeItems } = await createEnvelopeItems({ envelopeId: envelope.id, - items: envelopeItemsToCreate, + data: envelopeItemsToCreate, }).catch((error) => { console.error(error); diff --git a/assets/field-font-alignment.pdf b/assets/field-font-alignment.pdf new file mode 100644 index 000000000..35dad9311 Binary files /dev/null and b/assets/field-font-alignment.pdf differ diff --git a/assets/field-meta.pdf b/assets/field-meta.pdf new file mode 100644 index 000000000..c44bf8d68 Binary files /dev/null and b/assets/field-meta.pdf differ diff --git a/package-lock.json b/package-lock.json index ab1b56a2b..538dba498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12557,6 +12557,16 @@ "@types/pg": "*" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -27544,6 +27554,19 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -27740,6 +27763,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/pofile": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz", @@ -36183,7 +36216,10 @@ "@documenso/lib": "*", "@documenso/prisma": "*", "@playwright/test": "1.52.0", - "@types/node": "^20" + "@types/node": "^20", + "@types/pngjs": "^6.0.5", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0" } }, "packages/app-tests/node_modules/@playwright/test": { diff --git a/packages/app-tests/constants/field-alignment-pdf.ts b/packages/app-tests/constants/field-alignment-pdf.ts new file mode 100644 index 000000000..7f42d9ef5 --- /dev/null +++ b/packages/app-tests/constants/field-alignment-pdf.ts @@ -0,0 +1,498 @@ +import { FieldType } from '@prisma/client'; + +import type { TFieldAndMeta } from '@documenso/lib/types/field-meta'; +import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; + +export type FieldTestData = TFieldAndMeta & { + page: number; + positionX: number; + positionY: number; + width: number; + height: number; + customText: string; + signature?: string; +}; + +const columnWidth = 19.125; +const rowHeight = 6.7; + +const alignmentGridStartX = 31; +const alignmentGridStartY = 19.02; + +export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ + /** + * Row 1 EMAIL + */ + { + type: FieldType.EMAIL, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'email', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'admin@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { + textAlign: 'center', + type: 'email', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'admin@documenso.com', + }, + { + type: FieldType.EMAIL, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'email', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'admin@documenso.com', + }, + /** + * Row 2 NAME + */ + { + type: FieldType.NAME, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'name', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'John Doe', + }, + { + type: FieldType.NAME, + fieldMeta: { + textAlign: 'center', + type: 'name', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'John Doe', + }, + { + type: FieldType.NAME, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'name', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'John Doe', + }, + /** + * Row 3 DATE + */ + { + type: FieldType.DATE, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'date', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.DATE, + fieldMeta: { + textAlign: 'center', + type: 'date', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.DATE, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'date', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + /** + * Row 4 TEXT + */ + { + type: FieldType.TEXT, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'text', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'center', + type: 'text', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'text', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + /** + * Row 5 NUMBER + */ + { + type: FieldType.NUMBER, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'number', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'center', + type: 'number', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'number', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '123456789', + }, + /** + * Row 6 Initials + */ + { + type: FieldType.INITIALS, + fieldMeta: { + fontSize: 10, + textAlign: 'left', + type: 'initials', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'JD', + }, + { + type: FieldType.INITIALS, + fieldMeta: { + textAlign: 'center', + type: 'initials', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'JD', + }, + { + type: FieldType.INITIALS, + fieldMeta: { + fontSize: 20, + textAlign: 'right', + type: 'initials', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'JD', + }, + /** + * Row 7 Radio + */ + { + type: FieldType.RADIO, + fieldMeta: { + fontSize: 10, + direction: 'vertical', + type: 'radio', + values: [ + { id: 1, checked: true, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '0', + }, + { + type: FieldType.RADIO, + fieldMeta: { + direction: 'vertical', + type: 'radio', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: true, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '2', + }, + { + type: FieldType.RADIO, + fieldMeta: { + fontSize: 20, + direction: 'horizontal', + type: 'radio', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '', + }, + /** + * Row 8 Checkbox + */ + { + type: FieldType.CHECKBOX, + fieldMeta: { + fontSize: 10, + direction: 'vertical', + type: 'checkbox', + values: [ + { id: 1, checked: true, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: toCheckboxCustomText([0]), + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: true, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: toCheckboxCustomText([1]), + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + fontSize: 20, + direction: 'horizontal', + type: 'checkbox', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '', + }, + /** + * Row 8 Dropdown + */ + { + type: FieldType.DROPDOWN, + fieldMeta: { + fontSize: 10, + values: [{ value: 'Option 1' }, { value: 'Option 2' }], + type: 'dropdown', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'Option 1', + }, + { + type: FieldType.DROPDOWN, + fieldMeta: { + values: [{ value: 'Option 1' }, { value: 'Option 2' }], + type: 'dropdown', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'Option 1', + }, + { + type: FieldType.DROPDOWN, + fieldMeta: { + fontSize: 20, + values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }], + type: 'dropdown', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: 'Option 1', + }, + /** + * Row 9 Signature + */ + { + type: FieldType.SIGNATURE, + fieldMeta: { + fontSize: 10, + type: 'signature', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '', + signature: 'My Signature', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: { + type: 'signature', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '', + signature: 'My Signature', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: { + fontSize: 20, + type: 'signature', + }, + page: 1, + height: rowHeight, + width: columnWidth, + positionX: 0, + positionY: 0, + customText: '', + signature: 'My Signature', + }, +] as const; + +export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => { + const row = Math.floor(index / 3); + const column = index % 3; + + return { + ...field, + positionX: alignmentGridStartX + column * columnWidth, + positionY: alignmentGridStartY + row * rowHeight, + }; +}); diff --git a/packages/app-tests/constants/field-meta-pdf.ts b/packages/app-tests/constants/field-meta-pdf.ts new file mode 100644 index 000000000..36cb79590 --- /dev/null +++ b/packages/app-tests/constants/field-meta-pdf.ts @@ -0,0 +1,482 @@ +import { FieldType } from '@prisma/client'; + +import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; +import { + CheckboxValidationRules, + numberFormatValues, +} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; + +import type { FieldTestData } from './field-alignment-pdf'; + +const columnWidth = 20.1; +const fullColumnWidth = 75.8; +const rowHeight = 9.8; +const rowPadding = 1.8; + +const alignmentGridStartX = 11.85; +const alignmentGridStartY = 15.07; + +const calculatePosition = (row: number, column: number, width: 'full' | 'column' = 'column') => { + return { + height: rowHeight, + width: width === 'full' ? fullColumnWidth : columnWidth, + positionX: alignmentGridStartX + (column ?? 0) * columnWidth, + positionY: alignmentGridStartY + row * (rowHeight + rowPadding), + }; +}; + +export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ + /** + * PAGE 2 Signature + */ + { + type: FieldType.SIGNATURE, + fieldMeta: { + type: 'signature', + }, + page: 2, + ...calculatePosition(0, 0), + customText: '', + signature: 'My Signature', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: { + type: 'signature', + }, + page: 2, + ...calculatePosition(1, 0), + customText: '', + signature: 'My Signature', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: { + type: 'signature', + }, + page: 2, + ...calculatePosition(2, 0), + customText: '', + signature: 'My Signature', + }, + { + type: FieldType.SIGNATURE, + fieldMeta: { + type: 'signature', + }, + page: 2, + ...calculatePosition(3, 0), + customText: '', + signature: 'My Signature', + }, + + /** + * PAGE 3 TEXT + */ + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + }, + page: 3, + ...calculatePosition(0, 0, 'full'), + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + }, + page: 3, + ...calculatePosition(1, 0), + customText: '123456789123456789123456789123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + characterLimit: 5, + }, + page: 3, + ...calculatePosition(2, 0), + customText: '12345', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + placeholder: 'Demo Placeholder', + }, + page: 3, + ...calculatePosition(3, 0), + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + label: 'Demo Label', + }, + page: 3, + ...calculatePosition(3, 1), + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + text: 'Prefilled text', + }, + page: 3, + ...calculatePosition(3, 2), + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + required: true, + }, + page: 3, + ...calculatePosition(4, 0), + customText: '123456789', + }, + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + readOnly: true, + text: 'Readonly Value', + }, + page: 3, + ...calculatePosition(4, 1), + customText: 'Readonly Value', + }, + + /** + * PAGE 4 NUMBER + */ + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + }, + page: 4, + ...calculatePosition(0, 0, 'full'), + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + }, + page: 4, + ...calculatePosition(1, 0), + customText: '123456789123456789123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + minValue: 0, + maxValue: 100, + }, + page: 4, + ...calculatePosition(2, 0), + customText: '50', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + numberFormat: numberFormatValues[0].value, // Todo: Envelopes - Check this. + value: '123,456,789.00', + }, + page: 4, + ...calculatePosition(2, 1), + customText: '123,456,789.00', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + placeholder: 'Demo Placeholder', + }, + page: 4, + ...calculatePosition(3, 0), + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + label: 'Demo Label', + }, + page: 4, + ...calculatePosition(3, 1), + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + value: '123', + }, + page: 4, + ...calculatePosition(3, 2), + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + required: true, + }, + page: 4, + ...calculatePosition(4, 0), + customText: '123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + readOnly: true, + }, + page: 4, + ...calculatePosition(4, 1), + customText: '123456789', + }, + + /** + * PAGE 5 RADIO + */ + { + type: FieldType.RADIO, + fieldMeta: { + direction: 'horizontal', + type: 'radio', + values: [ + { id: 1, checked: true, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 5, + ...calculatePosition(0, 0, 'full'), + customText: '0', + }, + { + type: FieldType.RADIO, + fieldMeta: { + direction: 'vertical', + type: 'radio', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: true, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 5, + ...calculatePosition(1, 0), + customText: '2', + }, + { + type: FieldType.RADIO, + fieldMeta: { + direction: 'vertical', + type: 'radio', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 5, + ...calculatePosition(2, 0), + customText: '', + }, + { + type: FieldType.RADIO, + fieldMeta: { + direction: 'vertical', + type: 'radio', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 5, + ...calculatePosition(2, 1), + customText: '', + }, + + /** + * PAGE 6 CHECKBOX + */ + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'horizontal', + type: 'checkbox', + values: [ + { id: 1, checked: true, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 2, checked: false, value: 'Option 3' }, + { id: 2, checked: false, value: 'Option 4' }, + ], + }, + page: 6, + ...calculatePosition(0, 0, 'full'), + customText: toCheckboxCustomText([0]), + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: true, value: 'Option 2' }, + { id: 2, checked: true, value: 'Option 3' }, + ], + }, + page: 6, + ...calculatePosition(1, 0), + customText: toCheckboxCustomText([1]), + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + required: true, + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 6, + ...calculatePosition(2, 0), + customText: '', + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + readOnly: true, + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + ], + }, + page: 6, + ...calculatePosition(2, 1), + customText: '', + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + validationRule: CheckboxValidationRules.SELECT_AT_LEAST, + validationLength: 2, + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 6, + ...calculatePosition(3, 0), + customText: '', + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + validationRule: CheckboxValidationRules.SELECT_EXACTLY, + validationLength: 2, + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 6, + ...calculatePosition(3, 1), + customText: '', + }, + { + type: FieldType.CHECKBOX, + fieldMeta: { + direction: 'vertical', + type: 'checkbox', + validationRule: CheckboxValidationRules.SELECT_AT_MOST, + validationLength: 2, + values: [ + { id: 1, checked: false, value: 'Option 1' }, + { id: 2, checked: false, value: 'Option 2' }, + { id: 3, checked: false, value: 'Option 3' }, + ], + }, + page: 6, + ...calculatePosition(3, 2), + customText: '', + }, + + /** + * PAGE 7 DROPDOWN + */ + { + type: FieldType.DROPDOWN, + fieldMeta: { + values: [{ value: 'Option 1' }, { value: 'Option 2' }], + type: 'dropdown', + }, + page: 7, + ...calculatePosition(0, 0, 'full'), + customText: 'Option 1', + }, + { + type: FieldType.DROPDOWN, + fieldMeta: { + values: [{ value: 'Option 1' }, { value: 'Option 2' }], + type: 'dropdown', + defaultValue: 'Option 1', + }, + page: 7, + ...calculatePosition(1, 0), + customText: 'Option 1', + }, + { + type: FieldType.DROPDOWN, + fieldMeta: { + values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }], + type: 'dropdown', + required: true, + }, + page: 7, + ...calculatePosition(2, 0), + customText: 'Option 1', + }, + { + type: FieldType.DROPDOWN, + fieldMeta: { + values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }], + type: 'dropdown', + readOnly: true, + }, + page: 7, + ...calculatePosition(2, 1), + customText: 'Option 1', + }, +] as const; + +export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => { + return { + ...field, + }; +}); diff --git a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts new file mode 100644 index 000000000..4de9f402d --- /dev/null +++ b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts @@ -0,0 +1,264 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prefixedId } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { + DocumentSource, + DocumentVisibility, + EnvelopeType, + RecipientRole, +} from '@documenso/prisma/client'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; +import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; +import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; +import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; + +import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf'; +import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('API V2 Envelopes', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + /** + * Creates envelopes with the two field test PDFs. + */ + test('Envelope full test', async ({ request }) => { + // Step 1: Create initial envelope with Prisma (with first envelope item) + const alignmentPdf = fs + .readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf')) + .toString('base64'); + + const fieldMetaPdf = fs + .readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')) + .toString('base64'); + + const alignmentDocumentData = await prisma.documentData.create({ + data: { + type: 'BYTES_64', + data: alignmentPdf, + initialData: alignmentPdf, + }, + }); + + const documentId = await incrementDocumentId(); + const documentMeta = await prisma.documentMeta.create({ + data: {}, + }); + + const createdEnvelope = await prisma.envelope.create({ + data: { + id: prefixedId('envelope'), + secondaryId: documentId.formattedDocumentId, + internalVersion: 2, + type: EnvelopeType.DOCUMENT, + documentMetaId: documentMeta.id, + source: DocumentSource.DOCUMENT, + title: `Envelope Full Field Test`, + status: 'DRAFT', + userId: userA.id, + teamId: teamA.id, + envelopeItems: { + create: { + id: prefixedId('envelope_item'), + title: `Alignment Test`, + documentDataId: alignmentDocumentData.id, + order: 1, + }, + }, + }, + include: { + envelopeItems: true, + }, + }); + + // Step 2: Create second envelope item via API + const fieldMetaDocumentData = await prisma.documentData.create({ + data: { + type: 'BYTES_64', + data: fieldMetaPdf, + initialData: fieldMetaPdf, + }, + }); + + const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = { + envelopeId: createdEnvelope.id, + data: [ + { + title: 'Field Meta Test', + documentDataId: fieldMetaDocumentData.id, + }, + ], + }; + + const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: createEnvelopeItemsRequest, + }); + + expect(createItemsRes.ok()).toBeTruthy(); + expect(createItemsRes.status()).toBe(200); + + // Step 3: Update envelope via API + const updateEnvelopeRequest: TUpdateEnvelopeRequest = { + envelopeId: createdEnvelope.id, + envelopeType: EnvelopeType.DOCUMENT, + data: { + title: 'Envelope Full Field Test', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }; + + const updateRes = await request.post(`${baseUrl}/envelope/update`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: updateEnvelopeRequest, + }); + + expect(updateRes.ok()).toBeTruthy(); + expect(updateRes.status()).toBe(200); + + // Step 4: Create recipient via API + const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = { + envelopeId: createdEnvelope.id, + data: [ + { + email: userA.email, + name: userA.name || '', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ], + }; + + const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: createRecipientsRequest, + }); + + expect(createRecipientsRes.ok()).toBeTruthy(); + expect(createRecipientsRes.status()).toBe(200); + + // Step 5: Get envelope to retrieve recipients and envelope items + const getRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(getRes.ok()).toBeTruthy(); + expect(getRes.status()).toBe(200); + + const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse; + + const recipientId = envelopeResponse.recipients[0].id; + const alignmentItem = envelopeResponse.envelopeItems.find( + (item: { order: number }) => item.order === 1, + ); + const fieldMetaItem = envelopeResponse.envelopeItems.find( + (item: { order: number }) => item.order === 2, + ); + + expect(recipientId).toBeDefined(); + expect(alignmentItem).toBeDefined(); + expect(fieldMetaItem).toBeDefined(); + + if (!alignmentItem || !fieldMetaItem) { + throw new Error('Envelope items not found'); + } + + // Step 6: Create fields for first PDF (alignment fields) + const alignmentFieldsRequest = { + envelopeId: createdEnvelope.id, + data: formatAlignmentTestFields.map((field) => ({ + recipientId, + envelopeItemId: alignmentItem.id, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + fieldMeta: field.fieldMeta, + })), + }; + + const createAlignmentFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: alignmentFieldsRequest, + }); + + expect(createAlignmentFieldsRes.ok()).toBeTruthy(); + expect(createAlignmentFieldsRes.status()).toBe(200); + + // Step 7: Create fields for second PDF (field-meta fields) + const fieldMetaFieldsRequest = { + envelopeId: createdEnvelope.id, + data: FIELD_META_TEST_FIELDS.map((field) => ({ + recipientId, + envelopeItemId: fieldMetaItem.id, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + fieldMeta: field.fieldMeta, + })), + }; + + const createFieldMetaFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: fieldMetaFieldsRequest, + }); + + expect(createFieldMetaFieldsRes.ok()).toBeTruthy(); + expect(createFieldMetaFieldsRes.status()).toBe(200); + + // Step 8: Verify final envelope structure + const finalGetRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(finalGetRes.ok()).toBeTruthy(); + const finalEnvelope = (await finalGetRes.json()) as TGetEnvelopeResponse; + + // Verify structure + expect(finalEnvelope.envelopeItems.length).toBe(2); + expect(finalEnvelope.recipients.length).toBe(1); + expect(finalEnvelope.fields.length).toBe( + formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length, + ); + expect(finalEnvelope.title).toBe('Envelope Full Field Test'); + expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index 77686346e..86c95d1bc 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -11,6 +11,7 @@ import { import { prisma } from '@documenso/prisma'; import { DocumentVisibility, + EnvelopeType, FieldType, Prisma, ReadStatus, @@ -2856,4 +2857,1276 @@ test.describe('Document API V2', () => { expect(res.status()).toBe(200); }); }); + + test.describe('Envelope API V2', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test.describe('Envelope get endpoint', () => { + test('should block unauthorized access to envelope get endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}`, { + headers: { Authorization: `Bearer ${tokenB}` }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope get endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope update endpoint', () => { + test('should block unauthorized access to envelope update endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/update`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + envelopeType: EnvelopeType.DOCUMENT, + data: { title: 'Updated Title' }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope update endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/update`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + envelopeType: EnvelopeType.DOCUMENT, + data: { title: 'Updated Title' }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope delete endpoint', () => { + test('should block unauthorized access to envelope delete endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should allow authorized access to envelope delete endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope duplicate endpoint', () => { + test('should block unauthorized access to envelope duplicate endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/duplicate`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope duplicate endpoint', async ({ request }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/duplicate`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope distribute endpoint', () => { + test('should block unauthorized access to envelope distribute endpoint', async ({ + request, + }) => { + const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(500); + }); + + test('should allow authorized access to envelope distribute endpoint', async ({ + request, + }) => { + const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { envelopeId: doc.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope redistribute endpoint', () => { + test('should block unauthorized access to envelope redistribute endpoint', async ({ + request, + }) => { + const doc = await seedPendingDocument(userA, teamA.id, []); + + const userRecipient = await prisma.recipient.create({ + data: { + email: 'test@example.com', + name: 'Test', + token: nanoid(), + readStatus: ReadStatus.NOT_OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: null, + envelopeId: doc.id, + fields: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: '', + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + }, + }, + }, + }); + + const recipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: userRecipient.email, + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/redistribute`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + recipients: [recipient!.id], + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(500); + }); + + test('should allow authorized access to envelope redistribute endpoint', async ({ + request, + }) => { + const doc = await seedPendingDocument(userA, teamA.id, []); + + const userRecipient = await prisma.recipient.create({ + data: { + email: 'test@example.com', + name: 'Test', + token: nanoid(), + readStatus: ReadStatus.NOT_OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: null, + envelopeId: doc.id, + fields: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: '', + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + }, + }, + }, + }); + + const recipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: userRecipient.email, + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/redistribute`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + recipients: [recipient!.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope field get endpoint', () => { + test('should block unauthorized access to envelope field get endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: userRecipient.email, + }, + }); + + const field = await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: 'TEXT', + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/${field.id}`, { + headers: { Authorization: `Bearer ${tokenB}` }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope field get endpoint', async ({ request }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + envelopeId: doc.id, + email: userRecipient.email, + }, + }); + + const field = await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient.id, + type: 'TEXT', + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + }); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/${field.id}`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope field create-many endpoint', () => { + test('should block unauthorized access to envelope field create-many endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/create-many`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + recipientId: recipient!.id, + envelopeItemId: doc.envelopeItems[0].id, + type: 'TEXT', + page: 791.77, + positionX: 0, + positionY: 0, + width: 5, + height: 5, + fieldMeta: { type: 'text', label: 'First test field' }, + }, + { + recipientId: recipient!.id, + envelopeItemId: doc.envelopeItems[0].id, + type: 'TEXT', + page: 791.77, + positionX: 0, + positionY: 0, + width: 5, + height: 5, + fieldMeta: { type: 'text', label: 'Second test field' }, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope field create-many endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/create-many`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + recipientId: recipient!.id, + envelopeItemId: doc.envelopeItems[0].id, + type: 'TEXT', + page: 791.77, + positionX: 0, + positionY: 0, + width: 5, + height: 5, + fieldMeta: { type: 'text', label: 'First test field' }, + }, + { + recipientId: recipient!.id, + envelopeItemId: doc.envelopeItems[0].id, + type: 'TEXT', + page: 791.77, + positionX: 0, + positionY: 0, + width: 5, + height: 5, + fieldMeta: { type: 'text', label: 'Second test field' }, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope field update-many endpoint', () => { + test('should block unauthorized access to envelope field update-many endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const fields = await prisma.field.createManyAndReturn({ + data: [ + { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.TEXT, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.NUMBER, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + ], + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/update-many`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + id: fields[0].id, + type: FieldType.TEXT, + fieldMeta: { type: 'text', label: 'Updated first test field' }, + }, + { + id: fields[1].id, + type: FieldType.NUMBER, + fieldMeta: { type: 'number', label: 'Updated second test field' }, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope field update-many endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const fields = await prisma.field.createManyAndReturn({ + data: [ + { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.TEXT, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.NUMBER, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + ], + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/update-many`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + id: fields[0].id, + type: FieldType.TEXT, + fieldMeta: { type: 'text', label: 'Updated first test field' }, + }, + { + id: fields[1].id, + type: FieldType.NUMBER, + fieldMeta: { type: 'number', label: 'Updated second test field' }, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope field delete endpoint', () => { + test('should block unauthorized access to envelope field delete endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const field = await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.TEXT, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { fieldId: field.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope field delete endpoint', async ({ + request, + }) => { + const { user: userRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [userRecipient.email]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const field = await prisma.field.create({ + data: { + envelopeId: doc.id, + envelopeItemId: doc.envelopeItems[0].id, + recipientId: recipient!.id, + type: FieldType.TEXT, + page: 1, + positionX: 1, + positionY: 1, + width: 1, + height: 1, + customText: '', + inserted: false, + fieldMeta: { type: 'text', label: 'Test' }, + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/field/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { fieldId: field.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope recipient get endpoint', () => { + test('should block unauthorized access to envelope recipient get endpoint', async ({ + request, + }) => { + const { user: recipientUser } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [recipientUser]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/${recipient!.id}`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope recipient get endpoint', async ({ + request, + }) => { + const { user: recipientUser } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [recipientUser]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/${recipient!.id}`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope recipient create-many endpoint', () => { + test('should block unauthorized access to envelope recipient create-many endpoint', async ({ + request, + }) => { + const doc = await seedDraftDocument(userA, teamA.id, []); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + name: 'Test', + email: 'test@example.com', + role: RecipientRole.SIGNER, + }, + { + name: 'Test 2', + email: 'test2@example.com', + role: RecipientRole.SIGNER, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope recipient create-many endpoint', async ({ + request, + }) => { + const doc = await seedDraftDocument(userA, teamA.id, []); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + name: 'Test', + email: 'test@example.com', + role: RecipientRole.SIGNER, + }, + { + name: 'Test 2', + email: 'test2@example.com', + role: RecipientRole.SIGNER, + }, + ], + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope recipient update-many endpoint', () => { + test('should block unauthorized access to envelope recipient update-many endpoint', async ({ + request, + }) => { + const { user: firstRecipient } = await seedUser(); + const { user: secondRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [firstRecipient, secondRecipient]); + + const firstDocumentRecipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: firstRecipient.email, + }, + }); + + const secondDocumentRecipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: secondRecipient.email, + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/update-many`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + id: firstDocumentRecipient!.id, + name: 'Updated first recipient', + }, + { + id: secondDocumentRecipient!.id, + name: 'Updated second recipient', + }, + ], + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope recipient update-many endpoint', async ({ + request, + }) => { + const { user: firstRecipient } = await seedUser(); + const { user: secondRecipient } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [firstRecipient, secondRecipient]); + + const firstDocumentRecipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: firstRecipient.email, + }, + }); + + const secondDocumentRecipient = await prisma.recipient.findFirst({ + where: { + envelopeId: doc.id, + email: secondRecipient.email, + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/update-many`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + id: firstDocumentRecipient!.id, + name: 'Updated first recipient', + }, + { + id: secondDocumentRecipient!.id, + name: 'Updated second recipient', + }, + ], + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope recipient delete endpoint', () => { + test('should block unauthorized access to envelope recipient delete endpoint', async ({ + request, + }) => { + const { user: recipientUser } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [recipientUser]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { recipientId: recipient!.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope recipient delete endpoint', async ({ + request, + }) => { + const { user: recipientUser } = await seedUser(); + + const doc = await seedDraftDocument(userA, teamA.id, [recipientUser]); + + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { recipientId: recipient!.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope item create-many endpoint', () => { + test('should block unauthorized access to envelope item create-many endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const documentData = await prisma.documentData.create({ + data: { + type: 'BYTES_64', + data: Buffer.from('test pdf content').toString('base64'), + initialData: Buffer.from('test pdf content').toString('base64'), + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + title: 'New Item', + documentDataId: documentData.id, + }, + ], + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope item create-many endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const documentData = await prisma.documentData.create({ + data: { + type: 'BYTES_64', + data: Buffer.from('test pdf content').toString('base64'), + initialData: Buffer.from('test pdf content').toString('base64'), + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + title: 'New Item', + documentDataId: documentData.id, + }, + ], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope item update-many endpoint', () => { + test('should block unauthorized access to envelope item update-many endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/update-many`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: [ + { + envelopeItemId: envelopeItem.id, + title: 'Updated Item Title', + }, + ], + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope item update-many endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/update-many`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: [ + { + envelopeItemId: envelopeItem.id, + title: 'Updated Item Title', + }, + ], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope item delete endpoint', () => { + test('should block unauthorized access to envelope item delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + envelopeItemId: envelopeItem.id, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope item delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: doc.id }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + envelopeItemId: envelopeItem.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment find endpoint', () => { + test('should block unauthorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment create endpoint', () => { + test('should block unauthorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment update endpoint', () => { + test('should block unauthorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment delete endpoint', () => { + test('should block unauthorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { id: attachment.id }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { id: attachment.id }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + }); }); diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts new file mode 100644 index 000000000..75ad5dd17 --- /dev/null +++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts @@ -0,0 +1,267 @@ +// sort-imports-ignore + +// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ---- +import Module from 'module'; +import { Canvas, Image } from 'skia-canvas'; + +// Intercept require('canvas') and return skia-canvas equivalents +const originalRequire = Module.prototype.require; +Module.prototype.require = function (path: string) { + if (path === 'canvas') { + return { + createCanvas: (width: number, height: number) => new Canvas(width, height), + Image, // needed by pdfjs-dist + }; + } + // eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions + return originalRequire.apply(this, arguments as unknown as [string]); +}; + +import pixelMatch from 'pixelmatch'; +import { PNG } from 'pngjs'; +import type { TestInfo } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus } from '@prisma/client'; +import fs from 'node:fs'; +import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { prisma } from '@documenso/prisma'; +import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +test('field placement visual regression', async ({ page }, testInfo) => { + const { user, team } = await seedUser(); + + const envelope = await seedAlignmentTestDocument({ + userId: user.id, + teamId: team.id, + recipientName: user.name || '', + recipientEmail: user.email, + insertFields: true, + status: DocumentStatus.PENDING, + }); + + const token = envelope.recipients[0].token; + + const signUrl = `/sign/${token}`; + + await apiSignin({ + page, + email: user.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + }); + + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ + timeout: 10000, + }); + + const completedDocument = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + include: { + envelopeItems: { + orderBy: { + order: 'asc', + }, + include: { + documentData: true, + }, + }, + }, + }); + + const storedImages = fs.readdirSync(`packages/app-tests/visual-regression`); + + await Promise.all( + completedDocument.envelopeItems.map(async (item) => { + const pdfData = await getFile(item.documentData); + + const loadedImages = storedImages + .filter((image) => image.includes(item.title)) + .map((image) => fs.readFileSync(`packages/app-tests/visual-regression/${image}`)); + + await compareSignedPdfWithImages({ + id: item.title.replaceAll(' ', '-').toLowerCase(), + pdfData, + images: loadedImages, + testInfo, + }); + }), + ); +}); + +/** + * Used to download the envelope images when updating the visual regression test. + * + * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND. + */ +test.skip('download envelope images', async ({ page }) => { + const { user, team } = await seedUser(); + + const envelope = await seedAlignmentTestDocument({ + userId: user.id, + teamId: team.id, + recipientName: user.name || '', + recipientEmail: user.email, + insertFields: true, + status: DocumentStatus.PENDING, + }); + + const token = envelope.recipients[0].token; + + const signUrl = `/sign/${token}`; + + await apiSignin({ + page, + email: user.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await expect(async () => { + const { status } = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + }); + + expect(status).toBe(DocumentStatus.COMPLETED); + }).toPass({ + timeout: 10000, + }); + + const completedDocument = await prisma.envelope.findFirstOrThrow({ + where: { + id: envelope.id, + }, + include: { + envelopeItems: { + orderBy: { + order: 'asc', + }, + include: { + documentData: true, + }, + }, + }, + }); + + await Promise.all( + completedDocument.envelopeItems.map(async (item) => { + const pdfData = await getFile(item.documentData); + + const pdfImages = await renderPdfToImage(pdfData); + + for (const [index, { image }] of pdfImages.entries()) { + fs.writeFileSync( + `packages/app-tests/visual-regression/${item.title}-${index}.png`, + new Uint8Array(image), + ); + } + }), + ); +}); + +async function renderPdfToImage(pdfBytes: Uint8Array) { + const loadingTask = pdfjsLib.getDocument({ data: pdfBytes }); + const pdf = await loadingTask.promise; + + // Increase for higher resolution + const scale = 2; + + return await Promise.all( + Array.from({ length: pdf.numPages }, async (_, index) => { + const page = await pdf.getPage(index + 1); + + const viewport = page.getViewport({ scale }); + const virtualCanvas = new Canvas(viewport.width, viewport.height); + const context = virtualCanvas.getContext('2d'); + + // @ts-expect-error skia-canvas context satisfies runtime requirements for pdfjs + await page.render({ canvasContext: context, viewport }).promise; + + return { + image: await virtualCanvas.toBuffer('png'), + + // Rounded down because the certificate page somehow gives dimensions with decimals + width: Math.floor(viewport.width), + height: Math.floor(viewport.height), + }; + }), + ); +} + +type CompareSignedPdfWithImagesOptions = { + id: string; + pdfData: Uint8Array; + images: Buffer[]; + testInfo: TestInfo; +}; + +const compareSignedPdfWithImages = async ({ + id, + pdfData, + images, + testInfo, +}: CompareSignedPdfWithImagesOptions) => { + const renderedImages = await renderPdfToImage(pdfData); + + for (const [index, { image, width, height }] of renderedImages.entries()) { + const isCertificate = index === renderedImages.length - 1; + + const diff = new PNG({ width, height }); + + const storedImage = new Uint8Array(PNG.sync.read(images[index]).data); + + const newImage = new Uint8Array(PNG.sync.read(image).data); + + const comparison = pixelMatch( + storedImage, + newImage, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + diff.data as unknown as Uint8Array, + width, + height, + { + threshold: 0, + // includeAA: true, // This allows stricter testing. + }, + ); + console.log(`${id}-${index}: ${comparison}`); + + const filePath = testInfo.outputPath(`diff-${id}-${index}.png`); + + fs.writeFileSync(filePath, new Uint8Array(PNG.sync.write(diff))); + + if (isCertificate) { + expect(comparison).toBeLessThan(20000); + } else { + expect(comparison).toEqual(0); + } + } +}; diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json index 2df81aa87..2afae0845 100644 --- a/packages/app-tests/package.json +++ b/packages/app-tests/package.json @@ -15,7 +15,10 @@ "@documenso/lib": "*", "@documenso/prisma": "*", "@playwright/test": "1.52.0", - "@types/node": "^20" + "@types/node": "^20", + "@types/pngjs": "^6.0.5", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0" }, "dependencies": { "start-server-and-test": "^2.0.12" diff --git a/packages/app-tests/visual-regression/alignment-pdf-0.png b/packages/app-tests/visual-regression/alignment-pdf-0.png new file mode 100644 index 000000000..8464a9c9f Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-1.png b/packages/app-tests/visual-regression/alignment-pdf-1.png new file mode 100644 index 000000000..3f756ca3d Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-0.png b/packages/app-tests/visual-regression/field-meta-pdf-0.png new file mode 100644 index 000000000..f6e182a38 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-1.png b/packages/app-tests/visual-regression/field-meta-pdf-1.png new file mode 100644 index 000000000..843f2281a Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-2.png b/packages/app-tests/visual-regression/field-meta-pdf-2.png new file mode 100644 index 000000000..d0492f766 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-3.png b/packages/app-tests/visual-regression/field-meta-pdf-3.png new file mode 100644 index 000000000..c4a4238d7 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-4.png b/packages/app-tests/visual-regression/field-meta-pdf-4.png new file mode 100644 index 000000000..1db415d9e Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-4.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-5.png b/packages/app-tests/visual-regression/field-meta-pdf-5.png new file mode 100644 index 000000000..70a2f387b Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-5.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-6.png b/packages/app-tests/visual-regression/field-meta-pdf-6.png new file mode 100644 index 000000000..42b8aaaa3 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-6.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-7.png b/packages/app-tests/visual-regression/field-meta-pdf-7.png new file mode 100644 index 000000000..3f756ca3d Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-7.png differ diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index c9a982430..a7e77a27a 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -20,7 +20,12 @@ import { validateCheckboxLength } from '../../advanced-fields-validation/validat import { AppError, AppErrorCode } from '../../errors/app-error'; import { jobs } from '../../jobs/client'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZFieldAndMetaSchema, + ZRadioFieldMeta, +} from '../../types/field-meta'; import { ZWebhookDocumentSchema, mapEnvelopeToWebhookDocumentPayload, @@ -174,9 +179,20 @@ export const sendDocument = async ({ const fieldsToAutoInsert: { fieldId: number; customText: string }[] = []; - // Auto insert radio and checkboxes that have default values. + // Validate and autoinsert fields for V2 envelopes. if (envelope.internalVersion === 2) { - for (const field of envelope.fields) { + for (const unknownField of envelope.fields) { + const parsedField = ZFieldAndMetaSchema.safeParse(unknownField); + + if (parsedField.error) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message, + }); + } + + const field = parsedField.data; + const fieldId = unknownField.id; + if (field.type === FieldType.RADIO) { const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); @@ -184,7 +200,7 @@ export const sendDocument = async ({ if (checkedItemIndex !== -1) { fieldsToAutoInsert.push({ - fieldId: field.id, + fieldId, customText: toRadioCustomText(checkedItemIndex), }); } @@ -195,7 +211,7 @@ export const sendDocument = async ({ if (defaultValue && values.some((value) => value.value === defaultValue)) { fieldsToAutoInsert.push({ - fieldId: field.id, + fieldId, customText: defaultValue, }); } @@ -236,7 +252,7 @@ export const sendDocument = async ({ if (isValid) { fieldsToAutoInsert.push({ - fieldId: field.id, + fieldId, customText: toCheckboxCustomText(checkedIndices), }); } diff --git a/packages/lib/server-only/field/create-envelope-fields.ts b/packages/lib/server-only/field/create-envelope-fields.ts index 32a41b89f..3412d1e4a 100644 --- a/packages/lib/server-only/field/create-envelope-fields.ts +++ b/packages/lib/server-only/field/create-envelope-fields.ts @@ -26,9 +26,9 @@ export interface CreateEnvelopeFieldsOptions { envelopeItemId?: string; recipientId: number; - pageNumber: number; - pageX: number; - pageY: number; + page: number; + positionX: number; + positionY: number; width: number; height: number; })[]; @@ -122,9 +122,9 @@ export const createEnvelopeFields = async ({ const newlyCreatedFields = await tx.field.createManyAndReturn({ data: validatedFields.map((field) => ({ type: field.type, - page: field.pageNumber, - positionX: field.pageX, - positionY: field.pageY, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, width: field.width, height: field.height, customText: '', diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index ef1bf3000..c69a7f77a 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -158,7 +158,7 @@ export const setFieldsForDocument = async ({ const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const errors = validateNumberField( - String(numberFieldParsedMeta.value), + String(numberFieldParsedMeta.value || ''), numberFieldParsedMeta, false, ); diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index b71c15a3f..590b77d0e 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -188,7 +188,7 @@ export type TFieldMetaSchema = z.infer; export const ZFieldAndMetaSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal(FieldType.SIGNATURE), - fieldMeta: z.undefined(), + fieldMeta: ZSignatureFieldMeta.optional(), }), z.object({ type: z.literal(FieldType.FREE_SIGNATURE), diff --git a/packages/lib/types/field.ts b/packages/lib/types/field.ts index 23ec25acd..26aba6535 100644 --- a/packages/lib/types/field.ts +++ b/packages/lib/types/field.ts @@ -74,13 +74,13 @@ export const ZFieldWidthSchema = z.number().min(1).describe('The width of the fi export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.'); -export const ZClampedFieldPageXSchema = z +export const ZClampedFieldPositionXSchema = z .number() .min(0) .max(100) .describe('The percentage based X coordinate where the field will be placed.'); -export const ZClampedFieldPageYSchema = z +export const ZClampedFieldPositionYSchema = z .number() .min(0) .max(100) diff --git a/packages/lib/universal/field-renderer/render-checkbox-field.ts b/packages/lib/universal/field-renderer/render-checkbox-field.ts index 28a2cefdc..bb22bd419 100644 --- a/packages/lib/universal/field-renderer/render-checkbox-field.ts +++ b/packages/lib/universal/field-renderer/render-checkbox-field.ts @@ -3,6 +3,7 @@ import { match } from 'ts-pattern'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import type { TCheckboxFieldMeta } from '../../types/field-meta'; +import { parseCheckboxCustomText } from '../../utils/fields'; import { createFieldHoverInteraction, konvaTextFill, @@ -130,7 +131,7 @@ export const renderCheckboxFieldElement = ( pageLayer.batchDraw(); }); - const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : []; + const checkedValues: number[] = field.customText ? parseCheckboxCustomText(field.customText) : []; checkboxValues.forEach(({ value, checked }, index) => { const isCheckboxChecked = match(mode) @@ -170,7 +171,7 @@ export const renderCheckboxFieldElement = ( width: itemSize, height: itemSize, stroke: '#374151', - strokeWidth: 2, + strokeWidth: 1.5, cornerRadius: 2, fill: 'white', }); diff --git a/packages/lib/universal/field-renderer/render-radio-field.ts b/packages/lib/universal/field-renderer/render-radio-field.ts index fa329938a..5d173b0e3 100644 --- a/packages/lib/universal/field-renderer/render-radio-field.ts +++ b/packages/lib/universal/field-renderer/render-radio-field.ts @@ -159,7 +159,7 @@ export const renderRadioFieldElement = ( y: itemInputY, radius: calculateRadioSize(fontSize) / 2, stroke: '#374151', - strokeWidth: 2, + strokeWidth: 1.5, fill: 'white', }); diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts index d56fe084e..ec26192eb 100644 --- a/packages/lib/utils/fields.ts +++ b/packages/lib/utils/fields.ts @@ -81,6 +81,10 @@ export const mapFieldToLegacyField = ( }; export const parseCheckboxCustomText = (customText: string): number[] => { + if (!customText) { + return []; + } + return JSON.parse(customText); }; diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 3cb050bdb..352b9d23f 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -1,11 +1,22 @@ import fs from 'node:fs'; import path from 'node:path'; +import { formatAlignmentTestFields } from '@documenso/app-tests/constants/field-alignment-pdf'; +import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf'; +import { isBase64Image } from '@documenso/lib/constants/signatures'; import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id'; -import { prefixedId } from '@documenso/lib/universal/id'; +import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { prisma } from '..'; -import { DocumentDataType, DocumentSource, EnvelopeType } from '../client'; +import { + DocumentDataType, + DocumentSource, + DocumentStatus, + EnvelopeType, + ReadStatus, + SendStatus, + SigningStatus, +} from '../client'; import { seedPendingDocument } from './documents'; import { seedDirectTemplate, seedTemplate } from './templates'; import { seedUser } from './users'; @@ -155,7 +166,6 @@ export const seedDatabase = async () => { userId: exampleUser.user.id, teamId: exampleUser.team.id, }), - seedTemplate({ title: 'Template 1', userId: adminUser.user.id, @@ -166,5 +176,185 @@ export const seedDatabase = async () => { userId: adminUser.user.id, teamId: adminUser.team.id, }), + seedAlignmentTestDocument({ + userId: exampleUser.user.id, + teamId: exampleUser.team.id, + recipientName: exampleUser.user.name || '', + recipientEmail: exampleUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }), + seedAlignmentTestDocument({ + userId: exampleUser.user.id, + teamId: exampleUser.team.id, + recipientName: exampleUser.user.name || '', + recipientEmail: exampleUser.user.email, + insertFields: true, + status: DocumentStatus.PENDING, + }), + seedAlignmentTestDocument({ + userId: adminUser.user.id, + teamId: adminUser.team.id, + recipientName: adminUser.user.name || '', + recipientEmail: adminUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }), + seedAlignmentTestDocument({ + userId: adminUser.user.id, + teamId: adminUser.team.id, + recipientName: adminUser.user.name || '', + recipientEmail: adminUser.user.email, + insertFields: true, + status: DocumentStatus.PENDING, + }), ]); }; + +export const seedAlignmentTestDocument = async ({ + userId, + teamId, + recipientName, + recipientEmail, + insertFields, + status, +}: { + userId: number; + teamId: number; + recipientName: string; + recipientEmail: string; + insertFields: boolean; + status: DocumentStatus; +}) => { + const alignmentPdf = fs + .readFileSync(path.join(__dirname, '../../../assets/field-font-alignment.pdf')) + .toString('base64'); + + const fieldMetaPdf = fs + .readFileSync(path.join(__dirname, '../../../assets/field-meta.pdf')) + .toString('base64'); + + const alignmentDocumentData = await createDocumentData({ documentData: alignmentPdf }); + const fieldMetaDocumentData = await createDocumentData({ documentData: fieldMetaPdf }); + + const documentId = await incrementDocumentId(); + + const documentMeta = await prisma.documentMeta.create({ + data: {}, + }); + + const createdEnvelope = await prisma.envelope.create({ + data: { + id: prefixedId('envelope'), + secondaryId: documentId.formattedDocumentId, + internalVersion: 2, + type: EnvelopeType.DOCUMENT, + documentMetaId: documentMeta.id, + source: DocumentSource.DOCUMENT, + title: `Envelope Full Field Test`, + status, + envelopeItems: { + createMany: { + data: [ + { + id: prefixedId('envelope_item'), + title: `alignment-pdf`, + documentDataId: alignmentDocumentData.id, + order: 1, + }, + { + id: prefixedId('envelope_item'), + title: `field-meta-pdf`, + documentDataId: fieldMetaDocumentData.id, + order: 2, + }, + ], + }, + }, + userId, + teamId, + recipients: { + create: { + name: recipientName, + email: recipientEmail, + token: nanoid(), + sendStatus: status === 'DRAFT' ? SendStatus.NOT_SENT : SendStatus.SENT, + signingStatus: status === 'COMPLETED' ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + readStatus: status !== 'DRAFT' ? ReadStatus.OPENED : ReadStatus.NOT_OPENED, + }, + }, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); + + const { id, recipients, envelopeItems } = createdEnvelope; + + const recipientId = recipients[0].id; + const envelopeItemAlignmentItem = envelopeItems.find((item) => item.order === 1)?.id; + const envelopeItemFieldMetaItem = envelopeItems.find((item) => item.order === 2)?.id; + + if (!envelopeItemAlignmentItem || !envelopeItemFieldMetaItem) { + throw new Error('Envelope item not found'); + } + + await Promise.all( + formatAlignmentTestFields.map(async (field) => { + await prisma.field.create({ + data: { + ...field, + recipientId, + envelopeItemId: envelopeItemAlignmentItem, + envelopeId: id, + customText: insertFields ? field.customText : '', + inserted: insertFields, + signature: field.signature + ? { + create: { + recipientId, + signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, + typedSignature: isBase64Image(field.signature) ? null : field.signature, + }, + } + : undefined, + }, + }); + }), + ); + + await Promise.all( + FIELD_META_TEST_FIELDS.map(async (field) => { + await prisma.field.create({ + data: { + ...field, + recipientId, + envelopeItemId: envelopeItemFieldMetaItem, + envelopeId: id, + customText: insertFields ? field.customText : '', + inserted: insertFields, + signature: field.signature + ? { + create: { + recipientId, + signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, + typedSignature: isBase64Image(field.signature) ? null : field.signature, + }, + } + : undefined, + }, + }); + }), + ); + + return await prisma.envelope.findFirstOrThrow({ + where: { + id: createdEnvelope.id, + }, + include: { + recipients: true, + envelopeItems: true, + }, + }); +}; diff --git a/packages/trpc/server/envelope-router/attachment/create-attachment.ts b/packages/trpc/server/envelope-router/attachment/create-attachment.ts index 239052005..598f33c5b 100644 --- a/packages/trpc/server/envelope-router/attachment/create-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/create-attachment.ts @@ -13,7 +13,7 @@ export const createAttachmentRoute = authenticatedProcedure path: '/envelope/attachment/create', summary: 'Create attachment', description: 'Create a new attachment for an envelope', - tags: ['Envelope'], + tags: ['Envelope Attachment'], }, }) .input(ZCreateAttachmentRequestSchema) diff --git a/packages/trpc/server/envelope-router/attachment/delete-attachment.ts b/packages/trpc/server/envelope-router/attachment/delete-attachment.ts index 3fd82805e..99ea7dccc 100644 --- a/packages/trpc/server/envelope-router/attachment/delete-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/delete-attachment.ts @@ -13,7 +13,7 @@ export const deleteAttachmentRoute = authenticatedProcedure path: '/envelope/attachment/delete', summary: 'Delete attachment', description: 'Delete an attachment from an envelope', - tags: ['Envelope'], + tags: ['Envelope Attachment'], }, }) .input(ZDeleteAttachmentRequestSchema) diff --git a/packages/trpc/server/envelope-router/attachment/find-attachments.ts b/packages/trpc/server/envelope-router/attachment/find-attachments.ts index 74e81c5e1..b75ad8803 100644 --- a/packages/trpc/server/envelope-router/attachment/find-attachments.ts +++ b/packages/trpc/server/envelope-router/attachment/find-attachments.ts @@ -2,20 +2,20 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id'; import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token'; -import { procedure } from '../../trpc'; +import { maybeAuthenticatedProcedure } from '../../trpc'; import { ZFindAttachmentsRequestSchema, ZFindAttachmentsResponseSchema, } from './find-attachments.types'; -export const findAttachmentsRoute = procedure +export const findAttachmentsRoute = maybeAuthenticatedProcedure .meta({ openapi: { method: 'GET', path: '/envelope/attachment', summary: 'Find attachments', description: 'Find all attachments for an envelope', - tags: ['Envelope'], + tags: ['Envelope Attachment'], }, }) .input(ZFindAttachmentsRequestSchema) diff --git a/packages/trpc/server/envelope-router/attachment/update-attachment.ts b/packages/trpc/server/envelope-router/attachment/update-attachment.ts index 11a7dfa5b..9fc48eb70 100644 --- a/packages/trpc/server/envelope-router/attachment/update-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/update-attachment.ts @@ -13,7 +13,7 @@ export const updateAttachmentRoute = authenticatedProcedure path: '/envelope/attachment/update', summary: 'Update attachment', description: 'Update an existing attachment', - tags: ['Envelope'], + tags: ['Envelope Attachment'], }, }) .input(ZUpdateAttachmentRequestSchema) diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index 239393156..c5d0acffa 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -14,15 +14,15 @@ import { export const createEnvelopeItemsRoute = authenticatedProcedure // Todo: Envelopes - Pending direct uploads - // .meta({ - // openapi: { - // method: 'POST', - // path: '/envelope/item/create-many', - // summary: 'Create envelope items', - // description: 'Create multiple envelope items for an envelope', - // tags: ['Envelope Item'], - // }, - // }) + .meta({ + openapi: { + method: 'POST', + path: '/envelope/item/create-many', + summary: 'Create envelope items', + description: 'Create multiple envelope items for an envelope', + tags: ['Envelope Item'], + }, + }) .input(ZCreateEnvelopeItemsRequestSchema) .output(ZCreateEnvelopeItemsResponseSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts index ff62237fc..ab0dd4187 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts @@ -2,8 +2,8 @@ import { z } from 'zod'; import { ZClampedFieldHeightSchema, - ZClampedFieldPageXSchema, - ZClampedFieldPageYSchema, + ZClampedFieldPositionXSchema, + ZClampedFieldPositionYSchema, ZClampedFieldWidthSchema, ZFieldPageNumberSchema, ZFieldSchema, @@ -19,9 +19,9 @@ const ZCreateFieldSchema = ZFieldAndMetaSchema.and( .describe( 'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.', ), - pageNumber: ZFieldPageNumberSchema, - pageX: ZClampedFieldPageXSchema, - pageY: ZClampedFieldPageYSchema, + page: ZFieldPageNumberSchema, + positionX: ZClampedFieldPositionXSchema, + positionY: ZClampedFieldPositionYSchema, width: ZClampedFieldWidthSchema, height: ZClampedFieldHeightSchema, }), diff --git a/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.types.ts b/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.types.ts index b2604727b..7579db5c9 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.types.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.types.ts @@ -2,8 +2,8 @@ import { z } from 'zod'; import { ZClampedFieldHeightSchema, - ZClampedFieldPageXSchema, - ZClampedFieldPageYSchema, + ZClampedFieldPositionXSchema, + ZClampedFieldPositionYSchema, ZClampedFieldWidthSchema, ZFieldPageNumberSchema, ZFieldSchema, @@ -19,9 +19,9 @@ const ZUpdateFieldSchema = ZFieldAndMetaSchema.and( .describe( 'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.', ), - pageNumber: ZFieldPageNumberSchema.optional(), - pageX: ZClampedFieldPageXSchema.optional(), - pageY: ZClampedFieldPageYSchema.optional(), + page: ZFieldPageNumberSchema.optional(), + positionX: ZClampedFieldPositionXSchema.optional(), + positionY: ZClampedFieldPositionYSchema.optional(), width: ZClampedFieldWidthSchema.optional(), height: ZClampedFieldHeightSchema.optional(), }), diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 038c404e8..bdaf5c2c4 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -27,14 +27,18 @@ import { signEnvelopeFieldRoute } from './sign-envelope-field'; import { updateEnvelopeRoute } from './update-envelope'; import { updateEnvelopeItemsRoute } from './update-envelope-items'; +/** + * Note: The order of the routes is important for public API routes. + * + * Example: GET /envelope/attachment must appear before GET /envelope/:id + */ export const envelopeRouter = router({ - get: getEnvelopeRoute, - create: createEnvelopeRoute, - update: updateEnvelopeRoute, - delete: deleteEnvelopeRoute, - duplicate: duplicateEnvelopeRoute, - distribute: distributeEnvelopeRoute, - redistribute: redistributeEnvelopeRoute, + attachment: { + find: findAttachmentsRoute, + create: createAttachmentRoute, + update: updateAttachmentRoute, + delete: deleteAttachmentRoute, + }, item: { getMany: getEnvelopeItemsRoute, getManyByToken: getEnvelopeItemsByTokenRoute, @@ -57,10 +61,11 @@ export const envelopeRouter = router({ set: setEnvelopeFieldsRoute, sign: signEnvelopeFieldRoute, }, - attachment: { - find: findAttachmentsRoute, - create: createAttachmentRoute, - update: updateAttachmentRoute, - delete: deleteAttachmentRoute, - }, + get: getEnvelopeRoute, + create: createEnvelopeRoute, + update: updateEnvelopeRoute, + delete: deleteEnvelopeRoute, + duplicate: duplicateEnvelopeRoute, + distribute: distributeEnvelopeRoute, + redistribute: redistributeEnvelopeRoute, }); diff --git a/packages/trpc/server/envelope-router/set-envelope-fields.types.ts b/packages/trpc/server/envelope-router/set-envelope-fields.types.ts index 29ea2035a..19b10552a 100644 --- a/packages/trpc/server/envelope-router/set-envelope-fields.types.ts +++ b/packages/trpc/server/envelope-router/set-envelope-fields.types.ts @@ -3,8 +3,8 @@ import { z } from 'zod'; import { ZClampedFieldHeightSchema, - ZClampedFieldPageXSchema, - ZClampedFieldPageYSchema, + ZClampedFieldPositionXSchema, + ZClampedFieldPositionYSchema, ZClampedFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; @@ -26,8 +26,8 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({ .number() .min(1) .describe('The page number of the field on the envelope. Starts from 1.'), - positionX: ZClampedFieldPageXSchema, - positionY: ZClampedFieldPageYSchema, + positionX: ZClampedFieldPositionXSchema, + positionY: ZClampedFieldPositionYSchema, width: ZClampedFieldWidthSchema, height: ZClampedFieldHeightSchema, fieldMeta: ZFieldMetaSchema, diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 8f88ffcc1..92efab7fa 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -108,7 +108,14 @@ export const fieldRouter = router({ type: 'documentId', id: documentId, }, - fields: [field], + fields: [ + { + ...field, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + }, + ], requestMetadata: ctx.metadata, }); @@ -147,7 +154,12 @@ export const fieldRouter = router({ type: 'documentId', id: documentId, }, - fields, + fields: fields.map((field) => ({ + ...field, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + })), requestMetadata: ctx.metadata, }); }), @@ -335,7 +347,14 @@ export const fieldRouter = router({ type: 'templateId', id: templateId, }, - fields: [field], + fields: [ + { + ...field, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + }, + ], requestMetadata: ctx.metadata, }); @@ -408,7 +427,12 @@ export const fieldRouter = router({ type: 'templateId', id: templateId, }, - fields, + fields: fields.map((field) => ({ + ...field, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + })), requestMetadata: ctx.metadata, }); }), diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 96870c246..d67bfe1f2 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -164,14 +164,62 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat nonBatchedRequestId: alphaid(), }); - ctx.logger.info({ + const infoToLog: TrpcApiLog = { path, auth: ctx.metadata.auth, source: ctx.metadata.source, - userId: ctx.user?.id, - apiTokenId: null, trpcMiddleware: 'maybeAuthenticated', unverifiedTeamId: ctx.teamId, + }; + + const authorizationHeader = ctx.req.headers.get('authorization'); + + // Taken from `authenticatedMiddleware` in `@documenso/api/v1/middleware/authenticated.ts`. + if (authorizationHeader) { + // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" + const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0); + + if (!token) { + throw new Error('Token was not provided for authenticated middleware'); + } + + const apiToken = await getApiTokenByToken({ token }); + + ctx.logger.info({ + ...infoToLog, + userId: apiToken.user.id, + apiTokenId: apiToken.id, + } satisfies TrpcApiLog); + + return await next({ + ctx: { + ...ctx, + user: apiToken.user, + teamId: apiToken.teamId, + session: null, + metadata: { + ...ctx.metadata, + auditUser: apiToken.team + ? { + id: null, + email: null, + name: apiToken.team.name, + } + : { + id: apiToken.user.id, + email: apiToken.user.email, + name: apiToken.user.name, + }, + auth: 'api', + } satisfies ApiRequestMetadata, + }, + }); + } + + trpcSessionLogger.info({ + ...infoToLog, + userId: ctx.user?.id, + apiTokenId: null, } satisfies TrpcApiLog); return await next({ diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/constants.ts b/packages/ui/primitives/document-flow/field-items-advanced-settings/constants.ts index ebb8f38ae..c98da7451 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/constants.ts +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/constants.ts @@ -16,6 +16,12 @@ export const numberFormatValues = [ }, ]; +export enum CheckboxValidationRules { + SELECT_AT_LEAST = 'Select at least', + SELECT_EXACTLY = 'Select exactly', + SELECT_AT_MOST = 'Select at most', +} + export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most']; export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; export const checkboxValidationSigns = [