mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 09:41:35 +10:00
Merge branch 'main' into feat/auto-placing-fields
This commit is contained in:
@ -17,14 +17,14 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@ts-rest/core": "^3.30.5",
|
||||
"@ts-rest/open-api": "^3.33.0",
|
||||
"@ts-rest/serverless": "^3.30.5",
|
||||
"@ts-rest/core": "^3.52.0",
|
||||
"@ts-rest/open-api": "^3.52.0",
|
||||
"@ts-rest/serverless": "^3.52.0",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"superjson": "^2.2.5",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@ -20,12 +20,12 @@ import {
|
||||
getEnvelopeWhereInput,
|
||||
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
|
||||
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
|
||||
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
|
||||
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
|
||||
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRecipient = await updateDocumentRecipients({
|
||||
const updatedRecipient = await updateEnvelopeRecipients({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
id: {
|
||||
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
},
|
||||
});
|
||||
|
||||
const deletedRecipient = await deleteDocumentRecipient({
|
||||
const deletedRecipient = await deleteEnvelopeRecipient({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipientId: Number(recipientId),
|
||||
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const { fields } = await updateDocumentFields({
|
||||
const { fields } = await updateEnvelopeFields({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
documentId: legacyDocumentId,
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: legacyDocumentId,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: Number(fieldId),
|
||||
|
||||
498
packages/app-tests/constants/field-alignment-pdf.ts
Normal file
498
packages/app-tests/constants/field-alignment-pdf.ts
Normal file
@ -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,
|
||||
};
|
||||
});
|
||||
482
packages/app-tests/constants/field-meta-pdf.ts
Normal file
482
packages/app-tests/constants/field-meta-pdf.ts
Normal file
@ -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,
|
||||
};
|
||||
});
|
||||
560
packages/app-tests/e2e/api/v2/envelopes-api.spec.ts
Normal file
560
packages/app-tests/e2e/api/v2/envelopes-api.spec.ts
Normal file
@ -0,0 +1,560 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pick } from 'remeda';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
FolderType,
|
||||
RecipientRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.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,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope create endpoint', () => {
|
||||
test('should fail on invalid form', async ({ request }) => {
|
||||
const payload = {
|
||||
type: 'Invalid Type',
|
||||
title: 'Test Envelope',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('should create envelope with single file', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Test Envelope',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'field-font-alignment.pdf',
|
||||
data: fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: response.id,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope).toBeDefined();
|
||||
expect(envelope?.title).toBe('Test Envelope');
|
||||
expect(envelope?.type).toBe(EnvelopeType.TEMPLATE);
|
||||
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
|
||||
expect(envelope?.envelopeItems.length).toBe(1);
|
||||
expect(envelope?.envelopeItems[0].title).toBe('field-font-alignment.pdf');
|
||||
expect(envelope?.envelopeItems[0].documentDataId).toBeDefined();
|
||||
});
|
||||
|
||||
test('should create envelope with multiple file', async ({ request }) => {
|
||||
const folder = await prisma.folder.create({
|
||||
data: {
|
||||
name: 'Test Folder',
|
||||
teamId: teamA.id,
|
||||
userId: userA.id,
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = {
|
||||
title: 'Envelope Title',
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
externalId: 'externalId',
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
formValues: {
|
||||
hello: 'world',
|
||||
},
|
||||
folderId: folder.id,
|
||||
recipients: [
|
||||
{
|
||||
email: userA.email,
|
||||
name: 'Name',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: ['TWO_FACTOR_AUTH'],
|
||||
signingOrder: 1,
|
||||
fields: [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
identifier: 'field-font-alignment.pdf',
|
||||
page: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
identifier: 0,
|
||||
page: 1,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
subject: 'Subject',
|
||||
message: 'Message',
|
||||
timezone: 'Europe/Berlin',
|
||||
dateFormat: 'dd.MM.yyyy',
|
||||
distributionMethod: DocumentDistributionMethod.NONE,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
allowDictateNextSigner: true,
|
||||
redirectUrl: 'https://documenso.com',
|
||||
language: 'de',
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: false,
|
||||
emailReplyTo: userA.email,
|
||||
emailSettings: {
|
||||
recipientSigningRequest: false,
|
||||
recipientRemoved: false,
|
||||
recipientSigned: false,
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: true,
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
label: 'Test Attachment',
|
||||
data: 'https://documenso.com',
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'field-meta.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')),
|
||||
},
|
||||
{
|
||||
name: 'field-font-alignment.pdf',
|
||||
data: fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
// Should error since folder is not owned by the user.
|
||||
const invalidRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(invalidRes.ok()).toBeFalsy();
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: response.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
envelopeItems: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(userB.email);
|
||||
|
||||
expect(envelope.envelopeItems.length).toBe(2);
|
||||
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
|
||||
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
|
||||
|
||||
expect(envelope.title).toBe(payload.title);
|
||||
expect(envelope.type).toBe(payload.type);
|
||||
expect(envelope.externalId).toBe(payload.externalId);
|
||||
expect(envelope.visibility).toBe(payload.visibility);
|
||||
expect(envelope.authOptions).toEqual({
|
||||
globalAccessAuth: payload.globalAccessAuth,
|
||||
globalActionAuth: [],
|
||||
});
|
||||
expect(envelope.formValues).toEqual(payload.formValues);
|
||||
expect(envelope.folderId).toBe(payload.folderId);
|
||||
|
||||
expect(envelope.documentMeta.subject).toBe(payload.meta.subject);
|
||||
expect(envelope.documentMeta.message).toBe(payload.meta.message);
|
||||
expect(envelope.documentMeta.timezone).toBe(payload.meta.timezone);
|
||||
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
|
||||
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
|
||||
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
|
||||
payload.meta.allowDictateNextSigner,
|
||||
);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
|
||||
expect(envelope.documentMeta.language).toBe(payload.meta.language);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
|
||||
payload.meta.uploadSignatureEnabled,
|
||||
);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||
|
||||
expect([
|
||||
{
|
||||
label: envelope.envelopeAttachments[0].label,
|
||||
data: envelope.envelopeAttachments[0].data,
|
||||
type: envelope.envelopeAttachments[0].type,
|
||||
},
|
||||
]).toEqual(payload.attachments);
|
||||
|
||||
const field = envelope.fields[0];
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
expect({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
accessAuth: recipient.authOptions?.accessAuth,
|
||||
}).toEqual(
|
||||
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
|
||||
);
|
||||
|
||||
expect({
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX.toNumber(),
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
}).toEqual(
|
||||
pick(payload.recipients[0].fields[0], [
|
||||
'type',
|
||||
'page',
|
||||
'positionX',
|
||||
'positionY',
|
||||
'width',
|
||||
'height',
|
||||
]),
|
||||
);
|
||||
|
||||
// Expect string based ID to work.
|
||||
expect(field.envelopeItemId).toBe(
|
||||
envelope.envelopeItems.find((item) => item.title === 'field-font-alignment.pdf')?.id,
|
||||
);
|
||||
|
||||
// Expect index based ID to work.
|
||||
expect(envelope.fields[1].envelopeItemId).toBe(
|
||||
envelope.envelopeItems.find((item) => item.title === 'field-meta.pdf')?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
);
|
||||
|
||||
const fieldMetaPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Envelope Full Field Test',
|
||||
} satisfies TCreateEnvelopePayload),
|
||||
);
|
||||
|
||||
// Only add one file for now.
|
||||
formData.append(
|
||||
'files',
|
||||
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
|
||||
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createEnvelopeRequest.ok()).toBeTruthy();
|
||||
expect(createEnvelopeRequest.status()).toBe(200);
|
||||
|
||||
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
|
||||
|
||||
const getEnvelopeRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const createdEnvelope: TGetEnvelopeResponse = await getEnvelopeRequest.json();
|
||||
|
||||
// Might as well testing access control here as well.
|
||||
const unauthRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
});
|
||||
|
||||
expect(unauthRequest.ok()).toBeFalsy();
|
||||
expect(unauthRequest.status()).toBe(404);
|
||||
|
||||
// Step 2: Create second envelope item via API
|
||||
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
|
||||
envelopeId: createdEnvelope.id,
|
||||
};
|
||||
|
||||
const createEnvelopeItemFormData = new FormData();
|
||||
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
|
||||
createEnvelopeItemFormData.append(
|
||||
'files',
|
||||
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
|
||||
);
|
||||
|
||||
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: createEnvelopeItemFormData,
|
||||
});
|
||||
|
||||
expect(createItemsRes.ok()).toBeTruthy();
|
||||
expect(createItemsRes.status()).toBe(200);
|
||||
|
||||
// Step 3: Update envelope via API
|
||||
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
|
||||
envelopeId: createdEnvelope.id,
|
||||
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);
|
||||
|
||||
console.log({
|
||||
createdEnvelopeId: finalEnvelope.id,
|
||||
userEmail: userA.email,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
295
packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
Normal file
295
packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
Normal file
@ -0,0 +1,295 @@
|
||||
// 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 path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
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.skip('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(path.join(__dirname, '../../visual-regression'));
|
||||
|
||||
await Promise.all(
|
||||
completedDocument.envelopeItems.map(async (item) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const loadedImages = storedImages
|
||||
.filter((image) => image.includes(item.title))
|
||||
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
|
||||
|
||||
await compareSignedPdfWithImages({
|
||||
id: item.title.replaceAll(' ', '-').toLowerCase(),
|
||||
pdfData: new Uint8Array(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 documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
|
||||
|
||||
for (const [index, { image }] of pdfImages.entries()) {
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../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 = 4;
|
||||
|
||||
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');
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
// @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);
|
||||
|
||||
const blankCertificateFile = fs.readFileSync(
|
||||
path.join(__dirname, '../../visual-regression/blank-certificate.png'),
|
||||
);
|
||||
const blankCertificateImage = PNG.sync.read(blankCertificateFile).data;
|
||||
|
||||
for (const [index, { image, width, height }] of renderedImages.entries()) {
|
||||
const isCertificate = index === renderedImages.length - 1;
|
||||
|
||||
const diff = new PNG({ width, height });
|
||||
|
||||
const storedImage = PNG.sync.read(images[index]).data;
|
||||
|
||||
const newImage = PNG.sync.read(image).data;
|
||||
|
||||
const oldImage = isCertificate ? blankCertificateImage : storedImage;
|
||||
|
||||
const comparison = pixelMatch(
|
||||
new Uint8Array(oldImage),
|
||||
new Uint8Array(newImage),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
diff.data as unknown as Uint8Array,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
threshold: 0.25,
|
||||
// includeAA: true, // This allows stricter testing.
|
||||
},
|
||||
);
|
||||
console.log(`${id}-${index}: ${comparison}`);
|
||||
|
||||
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
|
||||
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
|
||||
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
|
||||
|
||||
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
|
||||
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
|
||||
fs.writeFileSync(newFilePath, new Uint8Array(image));
|
||||
|
||||
if (isCertificate) {
|
||||
// Expect the certificate to NOT be blank. Since the storedImage is blank.
|
||||
expect.soft(comparison).toBeGreaterThan(20000);
|
||||
} else {
|
||||
expect.soft(comparison).toEqual(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
@ -25,20 +25,26 @@ test.describe('Signing Certificate Tests', () => {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
const recipient = recipients[0];
|
||||
|
||||
const documentData = await prisma.envelopeItem
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
envelopeId: document.id,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
.then(async (data) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: data,
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
});
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
@ -78,10 +84,18 @@ test.describe('Signing Certificate Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
||||
const firstDocumentData = completedDocument.envelopeItems[0];
|
||||
|
||||
const completedDocumentData = await getFile(firstDocumentData);
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: firstDocumentData,
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const completedDocumentData = new Uint8Array(pdfData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
||||
@ -118,20 +132,26 @@ test.describe('Signing Certificate Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
const recipient = recipients[0];
|
||||
|
||||
const documentData = await prisma.envelopeItem
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
envelopeId: document.id,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
.then(async (data) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: data,
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
});
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
@ -169,10 +189,18 @@ test.describe('Signing Certificate Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
||||
const firstDocumentData = completedDocument.envelopeItems[0];
|
||||
|
||||
const completedDocumentData = await getFile(firstDocumentData);
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: firstDocumentData,
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const completedDocumentData = new Uint8Array(pdfData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
@ -209,19 +237,25 @@ test.describe('Signing Certificate Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
const recipient = recipients[0];
|
||||
|
||||
const documentData = await prisma.envelopeItem
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
envelopeId: document.id,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
.then(async (data) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: data,
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
});
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
@ -260,7 +294,16 @@ test.describe('Signing Certificate Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.envelopeItems[0].documentData);
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: completedDocument.envelopeItems[0],
|
||||
token: recipient.token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const completedDocumentData = await fetch(documentUrl).then(
|
||||
async (res) => await res.arrayBuffer(),
|
||||
);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
@ -12,6 +11,10 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
|
||||
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../../assets/field-font-alignment.pdf',
|
||||
);
|
||||
|
||||
/**
|
||||
* 1. Create a template with all settings filled out
|
||||
@ -233,10 +236,6 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
const { user, team } = await seedUser();
|
||||
const template = await seedBlankTemplate(user, team.id);
|
||||
|
||||
// Create a temporary PDF file for upload
|
||||
|
||||
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
@ -277,7 +276,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
|
||||
// Wait for upload to complete
|
||||
await expect(page.getByText('Remove')).toBeVisible();
|
||||
@ -314,8 +313,12 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
|
||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
@ -336,8 +339,6 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
|
||||
const template = await seedBlankTemplate(owner, team.id);
|
||||
|
||||
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
@ -378,7 +379,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
||||
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||
|
||||
// Wait for upload to complete
|
||||
await expect(page.getByText('Remove')).toBeVisible();
|
||||
@ -416,8 +417,12 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
|
||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
||||
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||
// Probably need to do a pixel match
|
||||
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).not.toEqual(
|
||||
template.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -20,6 +20,6 @@
|
||||
"luxon": "^3.5.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { deleteCookie } from 'hono/cookie';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { OAuthClientOptions } from '../../config';
|
||||
@ -177,6 +178,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
if (!isValidReturnTo(redirectPath)) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
|
||||
@ -19,6 +19,6 @@
|
||||
"micro": "^10.0.1",
|
||||
"react": "^18",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import type { EnvelopeItem } from '@prisma/client';
|
||||
|
||||
import { getFile } from '../universal/upload/get-file';
|
||||
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
|
||||
import { downloadFile } from './download-file';
|
||||
|
||||
type DocumentVersion = 'original' | 'signed';
|
||||
|
||||
type DownloadPDFProps = {
|
||||
documentData: DocumentData;
|
||||
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
token: string | undefined;
|
||||
|
||||
fileName?: string;
|
||||
/**
|
||||
* Specifies which version of the document to download.
|
||||
@ -17,18 +19,19 @@ type DownloadPDFProps = {
|
||||
};
|
||||
|
||||
export const downloadPDF = async ({
|
||||
documentData,
|
||||
envelopeItem,
|
||||
token,
|
||||
fileName,
|
||||
version = 'signed',
|
||||
}: DownloadPDFProps) => {
|
||||
const bytes = await getFile({
|
||||
type: documentData.type,
|
||||
data: version === 'signed' ? documentData.data : documentData.initialData,
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: envelopeItem,
|
||||
token,
|
||||
version,
|
||||
});
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
|
||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
|
||||
@ -165,10 +165,7 @@ export const useEditorFields = ({
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, {
|
||||
...localFields[index],
|
||||
id,
|
||||
});
|
||||
form.setValue(`fields.${index}.id`, id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Konva from 'konva';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
@ -25,6 +25,8 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||
*/
|
||||
@ -122,5 +124,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
unscaledViewport,
|
||||
scaledViewport,
|
||||
pageContext,
|
||||
renderError,
|
||||
setRenderError,
|
||||
};
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ type EnvelopeEditorProviderValue = {
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||
|
||||
@ -66,8 +67,6 @@ type EnvelopeEditorProviderValue = {
|
||||
};
|
||||
|
||||
syncEnvelope: () => Promise<void>;
|
||||
// refetchEnvelope: () => Promise<void>;
|
||||
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
|
||||
};
|
||||
|
||||
interface EnvelopeEditorProviderProps {
|
||||
@ -151,7 +150,7 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: ({ recipients }) => {
|
||||
onSuccess: ({ data: recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
@ -197,7 +196,7 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
envelopeFields.fields.forEach((field) => {
|
||||
envelopeFields.data.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
|
||||
if (localField && !localField.id) {
|
||||
@ -215,7 +214,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
data: envelopeUpdates.data,
|
||||
meta: envelopeUpdates.meta,
|
||||
});
|
||||
@ -237,6 +235,13 @@ export const EnvelopeEditorProvider = ({
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
...envelopeUpdates,
|
||||
});
|
||||
};
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
@ -324,6 +329,7 @@ export const EnvelopeEditorProvider = ({
|
||||
setLocalEnvelope,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
updateEnvelopeAsync,
|
||||
setRecipientsDebounced,
|
||||
setRecipientsAsync,
|
||||
editorFields,
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
|
||||
|
||||
type FileData =
|
||||
| {
|
||||
@ -18,19 +19,31 @@ type FileData =
|
||||
status: 'loaded';
|
||||
};
|
||||
|
||||
type EnvelopeRenderOverrideSettings = {
|
||||
mode?: FieldRenderMode;
|
||||
showRecipientTooltip?: boolean;
|
||||
showRecipientSigningStatus?: boolean;
|
||||
};
|
||||
|
||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||
|
||||
type EnvelopeRenderProviderValue = {
|
||||
getPdfBuffer: (documentDataId: string) => FileData | null;
|
||||
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
||||
envelopeItems: EnvelopeRenderItem[];
|
||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||
fields: TEnvelope['fields'];
|
||||
fields: Field[];
|
||||
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
renderError: boolean;
|
||||
setRenderError: (renderError: boolean) => void;
|
||||
overrideSettings?: EnvelopeRenderOverrideSettings;
|
||||
};
|
||||
|
||||
interface EnvelopeRenderProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
envelope: Pick<TEnvelope, 'envelopeItems'>;
|
||||
|
||||
/**
|
||||
@ -38,14 +51,27 @@ interface EnvelopeRenderProviderProps {
|
||||
*
|
||||
* Only pass if the CustomRenderer you are passing in wants fields.
|
||||
*/
|
||||
fields?: TEnvelope['fields'];
|
||||
fields?: Field[];
|
||||
|
||||
/**
|
||||
* Optional recipient IDs used to determine the color of the fields.
|
||||
* Optional recipient used to determine the color of the fields and hover
|
||||
* previews.
|
||||
*
|
||||
* Only required for generic page renderers.
|
||||
*/
|
||||
recipientIds?: number[];
|
||||
recipients?: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||
|
||||
/**
|
||||
* The token to access the envelope.
|
||||
*
|
||||
* If not provided, it will be assumed that the current user can access the document.
|
||||
*/
|
||||
token: string | undefined;
|
||||
|
||||
/**
|
||||
* Custom override settings for generic page renderers.
|
||||
*/
|
||||
overrideSettings?: EnvelopeRenderOverrideSettings;
|
||||
}
|
||||
|
||||
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
|
||||
@ -67,39 +93,51 @@ export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
envelope,
|
||||
fields,
|
||||
recipientIds = [],
|
||||
token,
|
||||
recipients = [],
|
||||
overrideSettings,
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
// Indexed by documentDataId.
|
||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||
|
||||
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
||||
[envelope.envelopeItems],
|
||||
);
|
||||
|
||||
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
|
||||
if (files[documentData.id]?.status === 'loading') {
|
||||
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
||||
if (files[envelopeItem.id]?.status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files[documentData.id]) {
|
||||
if (!files[envelopeItem.id]) {
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
[envelopeItem.id]: {
|
||||
status: 'loading',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await getFile(documentData);
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'view',
|
||||
envelopeItem: envelopeItem,
|
||||
token,
|
||||
});
|
||||
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
|
||||
const file = await blob.arrayBuffer();
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
file,
|
||||
[envelopeItem.id]: {
|
||||
file: new Uint8Array(file),
|
||||
status: 'loaded',
|
||||
},
|
||||
}));
|
||||
@ -108,7 +146,7 @@ export const EnvelopeRenderProvider = ({
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
[envelopeItem.id]: {
|
||||
status: 'error',
|
||||
},
|
||||
}));
|
||||
@ -116,8 +154,8 @@ export const EnvelopeRenderProvider = ({
|
||||
};
|
||||
|
||||
const getPdfBuffer = useCallback(
|
||||
(documentDataId: string) => {
|
||||
return files[documentDataId] || null;
|
||||
(envelopeItemId: string) => {
|
||||
return files[envelopeItemId] || null;
|
||||
},
|
||||
[files],
|
||||
);
|
||||
@ -137,13 +175,18 @@ export const EnvelopeRenderProvider = ({
|
||||
|
||||
// Look for any missing pdf files and load them.
|
||||
useEffect(() => {
|
||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
|
||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
|
||||
|
||||
for (const item of missingFiles) {
|
||||
void loadEnvelopeItemPdfFile(item.documentData);
|
||||
void loadEnvelopeItemPdfFile(item);
|
||||
}
|
||||
}, [envelope.envelopeItems]);
|
||||
|
||||
const recipientIds = useMemo(
|
||||
() => recipients.map((recipient) => recipient.id).sort(),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
|
||||
@ -163,7 +206,11 @@ export const EnvelopeRenderProvider = ({
|
||||
currentEnvelopeItem: currentItem,
|
||||
setCurrentEnvelopeItem,
|
||||
fields: fields ?? [],
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
renderError,
|
||||
setRenderError,
|
||||
overrideSettings,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -12,6 +12,7 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
export const API_V2_BETA_URL = '/api/v2-beta';
|
||||
export const API_V2_URL = '/api/v2';
|
||||
|
||||
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
|
||||
|
||||
|
||||
@ -55,11 +55,11 @@
|
||||
"skia-canvas": "^3.0.8",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.52.0",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,6 +78,14 @@ export const adminFindDocuments = async ({
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
title: true,
|
||||
order: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
|
||||
@ -248,6 +248,14 @@ export const findDocuments = async ({
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
title: true,
|
||||
order: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
|
||||
@ -92,6 +92,10 @@ export const getDocumentAndSenderByToken = async ({
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
envelopeId: true,
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
|
||||
@ -63,5 +63,8 @@ export const getDocumentWithDetailsById = async ({
|
||||
documentId: legacyDocumentId,
|
||||
password: null,
|
||||
},
|
||||
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
|
||||
...envelopeItem,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -234,9 +250,9 @@ export const sendDocument = async ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
if (isValid && checkedIndices.length > 0) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
fieldId,
|
||||
customText: toCheckboxCustomText(checkedIndices),
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,11 +17,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type {
|
||||
TDocumentAccessAuthTypes,
|
||||
TDocumentActionAuthTypes,
|
||||
TRecipientAccessAuthTypes,
|
||||
TRecipientActionAuthTypes,
|
||||
} from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import type { TFieldAndMeta } from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -35,6 +40,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
|
||||
documentDataId: string;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type CreateEnvelopeRecipientOptions = {
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
fields?: CreateEnvelopeRecipientFieldOptions[];
|
||||
};
|
||||
|
||||
export type CreateEnvelopeOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
@ -47,7 +71,6 @@ export type CreateEnvelopeOptions = {
|
||||
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
|
||||
formValues?: TDocumentFormValues;
|
||||
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
|
||||
templateType?: TemplateType;
|
||||
@ -57,7 +80,7 @@ export type CreateEnvelopeOptions = {
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
@ -84,7 +107,6 @@ export const createEnvelope = async ({
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
templateType,
|
||||
@ -143,6 +165,7 @@ export const createEnvelope = async ({
|
||||
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
|
||||
data.envelopeItems;
|
||||
|
||||
// Todo: Envelopes - Remove
|
||||
if (normalizePdf) {
|
||||
envelopeItems = await Promise.all(
|
||||
data.envelopeItems.map(async (item) => {
|
||||
@ -220,7 +243,7 @@ export const createEnvelope = async ({
|
||||
|
||||
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
|
||||
@ -143,7 +143,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
envelope,
|
||||
recipient: {
|
||||
...recipient,
|
||||
token: envelope.directLink?.token || '',
|
||||
directToken: envelope.directLink?.token || '',
|
||||
},
|
||||
recipientSignature: null,
|
||||
isRecipientsTurn: true,
|
||||
|
||||
@ -2,7 +2,6 @@ import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } fro
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||
@ -72,20 +71,11 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
.array(),
|
||||
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
envelopeId: true,
|
||||
id: true,
|
||||
title: true,
|
||||
documentDataId: true,
|
||||
order: true,
|
||||
})
|
||||
.extend({
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
}).array(),
|
||||
|
||||
team: TeamSchema.pick({
|
||||
id: true,
|
||||
@ -117,6 +107,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
directToken: z.string().nullish(),
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
@ -199,11 +190,7 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
fieldId: number;
|
||||
envelopeType: EnvelopeType;
|
||||
envelopeType?: EnvelopeType;
|
||||
};
|
||||
|
||||
export const getFieldById = async ({
|
||||
@ -41,7 +41,7 @@ export const getFieldById = async ({
|
||||
type: 'envelopeId',
|
||||
id: field.envelopeId,
|
||||
},
|
||||
type: envelopeType,
|
||||
type: envelopeType ?? null,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -10,18 +10,21 @@ import {
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { type EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateDocumentFieldsOptions {
|
||||
export interface UpdateEnvelopeFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
type?: EnvelopeType | null; // Only used to enforce the type.
|
||||
fields: {
|
||||
id: number;
|
||||
type?: FieldType;
|
||||
pageNumber?: number;
|
||||
envelopeItemId?: string;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
width?: number;
|
||||
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const updateDocumentFields = async ({
|
||||
export const updateEnvelopeFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
id,
|
||||
type = null,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentFieldsOptions) => {
|
||||
}: UpdateEnvelopeFieldsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id,
|
||||
type,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
message: 'Envelope already complete',
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const fieldType = field.type || originalField.type;
|
||||
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
|
||||
|
||||
// Not going to mess with V1 envelopes.
|
||||
if (
|
||||
envelope.internalVersion === 2 &&
|
||||
fieldMetaType &&
|
||||
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Field meta type does not match the field type',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
field.envelopeItemId &&
|
||||
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
originalField,
|
||||
updateData: field,
|
||||
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
|
||||
width: updateData.width,
|
||||
height: updateData.height,
|
||||
fieldMeta: updateData.fieldMeta,
|
||||
envelopeItemId: updateData.envelopeItemId,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = diffFieldChanges(originalField, updatedField);
|
||||
|
||||
// Handle field updated audit log.
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: updatedField.secondaryId,
|
||||
fieldRecipientEmail: recipientEmail,
|
||||
fieldRecipientId: updatedField.recipientId,
|
||||
fieldType: updatedField.type,
|
||||
changes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
const changes = diffFieldChanges(originalField, updatedField);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: updatedField.secondaryId,
|
||||
fieldRecipientEmail: recipientEmail,
|
||||
fieldRecipientId: updatedField.recipientId,
|
||||
fieldType: updatedField.type,
|
||||
changes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updatedField;
|
||||
@ -1,116 +0,0 @@
|
||||
import { EnvelopeType, type FieldType } from '@prisma/client';
|
||||
|
||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateTemplateFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
fields: {
|
||||
id: number;
|
||||
type?: FieldType;
|
||||
pageNumber?: number;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fieldMeta?: TFieldMetaSchema;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const updateTemplateFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
fields,
|
||||
}: UpdateTemplateFieldsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const fieldsToUpdate = fields.map((field) => {
|
||||
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
if (!originalField) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Field with id ${field.id} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(recipient) => recipient.id === originalField.recipientId,
|
||||
);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient attached to field ${field.id} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the recipient associated with the field can be modified.
|
||||
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'Cannot modify a field where the recipient has already interacted with the document',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updateData: field,
|
||||
};
|
||||
});
|
||||
|
||||
const updatedFields = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
fieldsToUpdate.map(async ({ updateData }) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
id: updateData.id,
|
||||
},
|
||||
data: {
|
||||
type: updateData.type,
|
||||
page: updateData.pageNumber,
|
||||
positionX: updateData.pageX,
|
||||
positionY: updateData.pageY,
|
||||
width: updateData.width,
|
||||
height: updateData.height,
|
||||
fieldMeta: updateData.fieldMeta,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedField;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
};
|
||||
};
|
||||
@ -1,14 +1,23 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
|
||||
import { removePlaceholdersFromPDF } from './auto-place-fields';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { flattenAnnotations } from './flatten-annotations';
|
||||
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
|
||||
|
||||
export const normalizePdf = async (pdf: Buffer) => {
|
||||
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
|
||||
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
|
||||
console.error(`PDF normalization error: ${e.message}`);
|
||||
|
||||
if (!pdfDoc) {
|
||||
return pdf;
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'The document is not a valid PDF',
|
||||
});
|
||||
});
|
||||
|
||||
if (pdfDoc.isEncrypted) {
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'The document is encrypted',
|
||||
});
|
||||
}
|
||||
|
||||
removeOptionalContentGroups(pdfDoc);
|
||||
|
||||
@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface CreateDocumentRecipientsOptions {
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const createDocumentRecipients = async ({
|
||||
export const createEnvelopeRecipients = async ({
|
||||
userId,
|
||||
teamId,
|
||||
id,
|
||||
recipients: recipientsToCreate,
|
||||
requestMetadata,
|
||||
}: CreateDocumentRecipientsOptions) => {
|
||||
}: CreateEnvelopeRecipientsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
type: null,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
message: 'Envelope already complete',
|
||||
});
|
||||
}
|
||||
|
||||
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
|
||||
});
|
||||
|
||||
// Handle recipient created audit log.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: createdRecipient.email,
|
||||
recipientName: createdRecipient.name,
|
||||
recipientId: createdRecipient.id,
|
||||
recipientRole: createdRecipient.role,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: createdRecipient.email,
|
||||
recipientName: createdRecipient.name,
|
||||
recipientId: createdRecipient.id,
|
||||
recipientRole: createdRecipient.role,
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return createdRecipient;
|
||||
}),
|
||||
@ -1,115 +0,0 @@
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface CreateTemplateRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export const createTemplateRecipients = async ({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
recipients: recipientsToCreate,
|
||||
}: CreateTemplateRecipientsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const template = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
const createdRecipient = await tx.recipient.create({
|
||||
data: {
|
||||
envelopeId: template.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return createdRecipient;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
recipients: createdRecipients.map((recipient) =>
|
||||
mapRecipientToLegacyRecipient(recipient, template),
|
||||
),
|
||||
};
|
||||
};
|
||||
@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteDocumentRecipientOptions {
|
||||
export interface DeleteEnvelopeRecipientOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const deleteDocumentRecipient = async ({
|
||||
export const deleteEnvelopeRecipient = async ({
|
||||
userId,
|
||||
teamId,
|
||||
recipientId,
|
||||
requestMetadata,
|
||||
}: DeleteDocumentRecipientOptions) => {
|
||||
}: DeleteEnvelopeRecipientOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipientToDelete.email,
|
||||
recipientName: recipientToDelete.name,
|
||||
recipientId: recipientToDelete.id,
|
||||
recipientRole: recipientToDelete.role,
|
||||
},
|
||||
}),
|
||||
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Recipient has already interacted with the document.',
|
||||
});
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
type: null,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipientToDelete.email,
|
||||
recipientName: recipientToDelete.name,
|
||||
recipientId: recipientToDelete.id,
|
||||
recipientRole: recipientToDelete.role,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.recipient.delete({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
|
||||
).recipientRemoved;
|
||||
|
||||
// Send email to deleted recipient.
|
||||
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
|
||||
if (
|
||||
recipientToDelete.sendStatus === SendStatus.SENT &&
|
||||
isRecipientRemovedEmailEnabled &&
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
) {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
@ -1,58 +0,0 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteTemplateRecipientOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const deleteTemplateRecipient = async ({
|
||||
userId,
|
||||
teamId,
|
||||
recipientId,
|
||||
}: DeleteTemplateRecipientOptions): Promise<void> => {
|
||||
const recipientToDelete = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipientToDelete) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: recipientToDelete.envelopeId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.recipient.delete({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,4 @@
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { extractLegacyIds } from '../../universal/id';
|
||||
import { type EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateDocumentRecipientsOptions {
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
recipients: RecipientData[];
|
||||
recipients: {
|
||||
id: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const updateDocumentRecipients = async ({
|
||||
export const updateEnvelopeRecipients = async ({
|
||||
userId,
|
||||
teamId,
|
||||
id,
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentRecipientsOptions) => {
|
||||
}: UpdateEnvelopeRecipientsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
type: null,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
message: 'Envelope already complete',
|
||||
});
|
||||
}
|
||||
|
||||
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
|
||||
|
||||
// Handle recipient updated audit log.
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: updatedRecipient.email,
|
||||
recipientName: updatedRecipient.name,
|
||||
recipientId: updatedRecipient.id,
|
||||
recipientRole: updatedRecipient.role,
|
||||
changes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: updatedRecipient.email,
|
||||
recipientName: updatedRecipient.name,
|
||||
recipientId: updatedRecipient.id,
|
||||
recipientRole: updatedRecipient.role,
|
||||
changes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRecipient;
|
||||
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
|
||||
return {
|
||||
recipients: updatedRecipients.map((recipient) => ({
|
||||
...recipient,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
templateId: null,
|
||||
...extractLegacyIds(envelope),
|
||||
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type RecipientData = {
|
||||
id: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
};
|
||||
@ -1,168 +0,0 @@
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateTemplateRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
id: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
signingOrder?: number | null;
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export const updateTemplateRecipients = async ({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
recipients,
|
||||
}: UpdateTemplateRecipientsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
const originalRecipient = envelope.recipients.find(
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
if (!originalRecipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient with id ${recipient.id} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
recipientUpdateData: recipient,
|
||||
};
|
||||
});
|
||||
|
||||
const updatedRecipients = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
|
||||
|
||||
if (
|
||||
recipientUpdateData.actionAuth !== undefined ||
|
||||
recipientUpdateData.accessAuth !== undefined
|
||||
) {
|
||||
authOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
|
||||
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
|
||||
});
|
||||
}
|
||||
|
||||
const mergedRecipient = {
|
||||
...originalRecipient,
|
||||
...recipientUpdateData,
|
||||
};
|
||||
|
||||
const updatedRecipient = await tx.recipient.update({
|
||||
where: {
|
||||
id: originalRecipient.id,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
data: {
|
||||
name: mergedRecipient.name,
|
||||
email: mergedRecipient.email,
|
||||
role: mergedRecipient.role,
|
||||
signingOrder: mergedRecipient.signingOrder,
|
||||
envelopeId: envelope.id,
|
||||
sendStatus:
|
||||
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
mergedRecipient.role === RecipientRole.CC
|
||||
? SigningStatus.SIGNED
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
authOptions,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear all fields if the recipient role is changed to a type that cannot have fields.
|
||||
if (
|
||||
originalRecipient.role !== updatedRecipient.role &&
|
||||
(updatedRecipient.role === RecipientRole.CC ||
|
||||
updatedRecipient.role === RecipientRole.VIEWER)
|
||||
) {
|
||||
await tx.field.deleteMany({
|
||||
where: {
|
||||
recipientId: updatedRecipient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedRecipient;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
recipients: updatedRecipients.map((recipient) => ({
|
||||
...recipient,
|
||||
documentId: null,
|
||||
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@ -82,6 +82,7 @@ type CreatedDirectRecipientField = {
|
||||
|
||||
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
envelopeId: z.string(),
|
||||
documentId: z.number(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
@ -815,6 +816,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
|
||||
return {
|
||||
token,
|
||||
envelopeId: createdEnvelope.id,
|
||||
documentId: incrementedDocumentId.documentId,
|
||||
recipientId,
|
||||
};
|
||||
|
||||
@ -87,5 +87,9 @@ export const getTemplateByDirectLinkToken = async ({
|
||||
},
|
||||
recipients: recipientsWithMappedFields,
|
||||
fields: recipientsWithMappedFields.flatMap((recipient) => recipient.fields),
|
||||
envelopeItems: envelope.envelopeItems.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeId: item.envelopeId,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
@ -94,5 +95,9 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
|
||||
}
|
||||
: null,
|
||||
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
|
||||
id: envelopeItem.id,
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -53,7 +53,7 @@ msgstr "\"Team Name\" has invited you to sign \"example document\"."
|
||||
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
msgid "(You)"
|
||||
msgstr "(You)"
|
||||
|
||||
@ -1076,6 +1076,10 @@ msgstr "Add Placeholder Recipient"
|
||||
msgid "Add Placeholders"
|
||||
msgstr "Add Placeholders"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Add Recipients"
|
||||
msgstr "Add Recipients"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
@ -1124,6 +1128,8 @@ msgstr "Add this URL to your provider's allowed redirect URIs"
|
||||
msgid "Additional brand information to display at the bottom of emails"
|
||||
msgstr "Additional brand information to display at the bottom of emails"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: packages/lib/constants/teams-translations.ts
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Admin"
|
||||
@ -1363,17 +1369,6 @@ msgstr "An error occurred while disabling direct link signing."
|
||||
msgid "An error occurred while disabling the user."
|
||||
msgstr "An error occurred while disabling the user."
|
||||
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/share-document-download-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-button.tsx
|
||||
msgid "An error occurred while downloading your document."
|
||||
msgstr "An error occurred while downloading your document."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
msgid "An error occurred while duplicating template."
|
||||
msgstr "An error occurred while duplicating template."
|
||||
@ -1455,6 +1450,10 @@ msgstr "An error occurred while signing as assistant."
|
||||
msgid "An error occurred while signing the document."
|
||||
msgstr "An error occurred while signing the document."
|
||||
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
|
||||
msgid "An error occurred while signing the field."
|
||||
msgstr "An error occurred while signing the field."
|
||||
|
||||
#: apps/remix/app/components/general/billing-plans.tsx
|
||||
#: apps/remix/app/components/general/billing-plans.tsx
|
||||
msgid "An error occurred while trying to create a checkout session."
|
||||
@ -1538,6 +1537,7 @@ msgstr "An unexpected error occurred."
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/account-delete-dialog.tsx
|
||||
@ -1694,7 +1694,7 @@ msgstr "Assist"
|
||||
msgid "Assist Document"
|
||||
msgstr "Assist Document"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
msgid "Assist with signing"
|
||||
msgstr "Assist with signing"
|
||||
|
||||
@ -2038,6 +2038,7 @@ msgstr "Can prepare"
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
@ -2165,10 +2166,6 @@ msgstr "Clear filters"
|
||||
msgid "Clear Signature"
|
||||
msgstr "Clear Signature"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Click here to add a recipient"
|
||||
msgstr "Click here to add a recipient"
|
||||
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
msgid "Click here to get started"
|
||||
msgstr "Click here to get started"
|
||||
@ -2191,7 +2188,7 @@ msgstr "Click to copy signing link for sending to recipient"
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
msgid "Click to insert field"
|
||||
@ -2234,10 +2231,6 @@ msgstr "Client secret is required"
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
|
||||
msgid "Coming soon"
|
||||
msgstr "Coming soon"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Communication"
|
||||
msgstr "Communication"
|
||||
@ -2249,8 +2242,8 @@ msgstr "Compare all plans and features in detail"
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
@ -2296,6 +2289,12 @@ msgstr "Completed Documents"
|
||||
msgid "Completed on {formattedDate}"
|
||||
msgstr "Completed on {formattedDate}"
|
||||
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
msgid "Configuration Error"
|
||||
msgstr "Configuration Error"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
msgid "Configure {0} Field"
|
||||
@ -3567,20 +3566,17 @@ msgstr "Domain Name"
|
||||
msgid "Don't have an account? <0>Sign up</0>"
|
||||
msgstr "Don't have an account? <0>Sign up</0>"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/share-document-download-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
|
||||
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
|
||||
#: apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx
|
||||
#: apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx
|
||||
#: packages/ui/components/document/document-download-button.tsx
|
||||
#: packages/email/template-components/template-document-completed.tsx
|
||||
msgid "Download"
|
||||
msgstr "Download"
|
||||
@ -3597,11 +3593,6 @@ msgstr "Download Certificate"
|
||||
msgid "Download Files"
|
||||
msgstr "Download Files"
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Download Original"
|
||||
msgstr "Download Original"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
|
||||
@ -3752,7 +3743,7 @@ msgstr "Electronic Signature Disclosure"
|
||||
#: apps/remix/app/components/forms/signin.tsx
|
||||
#: apps/remix/app/components/forms/profile.tsx
|
||||
#: apps/remix/app/components/forms/forgot-password.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
@ -4023,10 +4014,6 @@ msgstr "Enter your text here"
|
||||
msgid "Enterprise"
|
||||
msgstr "Enterprise"
|
||||
|
||||
#: packages/ui/primitives/document-upload.tsx
|
||||
msgid "Envelope (beta)"
|
||||
msgstr "Envelope (beta)"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "Envelope distributed"
|
||||
msgstr "Envelope distributed"
|
||||
@ -4072,7 +4059,6 @@ msgstr "Envelope updated"
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
|
||||
#: apps/remix/app/components/general/verify-email-banner.tsx
|
||||
#: apps/remix/app/components/general/template/template-edit-form.tsx
|
||||
@ -4081,6 +4067,7 @@ msgstr "Envelope updated"
|
||||
#: apps/remix/app/components/general/template/template-edit-form.tsx
|
||||
#: apps/remix/app/components/general/template/template-edit-form.tsx
|
||||
#: apps/remix/app/components/general/template/template-edit-form.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
|
||||
@ -4429,7 +4416,7 @@ msgstr "Free Signature Settings"
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/forms/profile.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
msgid "Full Name"
|
||||
@ -4574,7 +4561,7 @@ msgstr "has invited you to view this document"
|
||||
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
|
||||
msgstr "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
msgid "Help complete the document for other signers."
|
||||
msgstr "Help complete the document for other signers."
|
||||
|
||||
@ -5252,6 +5239,8 @@ msgstr "Manage your passkeys."
|
||||
msgid "Manage your site settings here"
|
||||
msgstr "Manage your site settings here"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: packages/lib/constants/teams-translations.ts
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Manager"
|
||||
@ -5300,6 +5289,8 @@ msgstr "Maximum number of uploaded files per envelope allowed"
|
||||
#: apps/remix/app/components/tables/team-members-table.tsx
|
||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: packages/lib/constants/teams-translations.ts
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Member"
|
||||
@ -5310,10 +5301,6 @@ msgstr "Member"
|
||||
msgid "Member Count"
|
||||
msgstr "Member Count"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Member promoted to owner successfully"
|
||||
msgstr "Member promoted to owner successfully"
|
||||
|
||||
#: apps/remix/app/components/tables/organisation-members-table.tsx
|
||||
msgid "Member Since"
|
||||
msgstr "Member Since"
|
||||
@ -5346,6 +5333,10 @@ msgstr "Message <0>(Optional)</0>"
|
||||
msgid "Min"
|
||||
msgstr "Min"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Missing Recipients"
|
||||
msgstr "Missing Recipients"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Modify recipients"
|
||||
@ -5484,7 +5475,7 @@ msgstr "New Password"
|
||||
msgid "New Template"
|
||||
msgstr "New Template"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
|
||||
@ -5932,9 +5923,15 @@ msgstr "Override organisation settings"
|
||||
#: apps/remix/app/components/tables/organisation-members-table.tsx
|
||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Ownership transferred to {organisationMemberName}."
|
||||
msgstr "Ownership transferred to {organisationMemberName}."
|
||||
|
||||
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||
#. placeholder {1}: table.getPageCount() || 1
|
||||
#: packages/ui/primitives/data-table-pagination.tsx
|
||||
@ -6039,6 +6036,7 @@ msgstr "PDF Document"
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-header.tsx
|
||||
#: apps/remix/app/components/general/document/document-status.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
|
||||
#: packages/ui/components/document/document-read-only-fields.tsx
|
||||
#: packages/lib/constants/document.ts
|
||||
msgid "Pending"
|
||||
@ -6376,10 +6374,6 @@ msgstr "Profile updated"
|
||||
msgid "Progress"
|
||||
msgstr "Progress"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Promote to owner"
|
||||
msgstr "Promote to owner"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Provider"
|
||||
@ -6880,6 +6874,7 @@ msgstr "Right"
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
@ -7339,7 +7334,7 @@ msgstr "Sign as<0>{0} <1>({1})</1></0>"
|
||||
msgid "Sign Checkbox Field"
|
||||
msgstr "Sign Checkbox Field"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
msgid "Sign document"
|
||||
@ -7407,7 +7402,7 @@ msgstr "Sign Signature Field"
|
||||
msgid "Sign Text Field"
|
||||
msgstr "Sign Text Field"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
msgid "Sign the document to complete the process."
|
||||
@ -7449,7 +7444,7 @@ msgstr "Sign your initials into the field"
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
|
||||
#: apps/remix/app/components/forms/profile.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
@ -7483,13 +7478,10 @@ msgstr "Signature types"
|
||||
msgid "Signatures Collected"
|
||||
msgstr "Signatures Collected"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "Signatures will appear once the document has been completed"
|
||||
msgstr "Signatures will appear once the document has been completed"
|
||||
|
||||
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
|
||||
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
|
||||
#: packages/ui/components/document/document-read-only-fields.tsx
|
||||
msgid "Signed"
|
||||
msgstr "Signed"
|
||||
@ -7535,7 +7527,7 @@ msgstr "Signing certificate provided by"
|
||||
msgid "Signing Complete!"
|
||||
msgstr "Signing Complete!"
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
msgid "Signing for"
|
||||
msgstr "Signing for"
|
||||
|
||||
@ -7595,11 +7587,6 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
|
||||
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
|
||||
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
|
||||
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/share-document-download-button.tsx
|
||||
#: apps/remix/app/components/general/billing-plans.tsx
|
||||
#: apps/remix/app/components/general/billing-plans.tsx
|
||||
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
|
||||
@ -7610,16 +7597,14 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
|
||||
#: apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx
|
||||
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-certificate-download-button.tsx
|
||||
#: apps/remix/app/components/general/document/document-audit-log-download-button.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
@ -7658,6 +7643,11 @@ msgstr "Something went wrong while loading the document."
|
||||
msgid "Something went wrong while loading your passkeys."
|
||||
msgstr "Something went wrong while loading your passkeys."
|
||||
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
msgid "Something went wrong while rendering the document, some fields may be missing or corrupted."
|
||||
msgstr "Something went wrong while rendering the document, some fields may be missing or corrupted."
|
||||
|
||||
#: apps/remix/app/components/general/verify-email-banner.tsx
|
||||
msgid "Something went wrong while sending the confirmation email."
|
||||
msgstr "Something went wrong while sending the confirmation email."
|
||||
@ -7808,7 +7798,6 @@ msgstr "Subscription invalid"
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
|
||||
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
|
||||
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
|
||||
@ -7853,6 +7842,7 @@ msgstr "Subscription invalid"
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
msgid "Success"
|
||||
msgstr "Success"
|
||||
@ -8523,6 +8513,10 @@ msgstr "The token you have used to reset your password is either expired or it n
|
||||
msgid "The two-factor authentication code provided is incorrect"
|
||||
msgstr "The two-factor authentication code provided is incorrect"
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-signature-form.tsx
|
||||
msgid "The typed signature font size"
|
||||
msgstr "The typed signature font size"
|
||||
|
||||
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
|
||||
msgid "The types of signatures that recipients are allowed to use when signing the document."
|
||||
msgstr "The types of signatures that recipients are allowed to use when signing the document."
|
||||
@ -8576,6 +8570,10 @@ msgstr "There are no completed documents yet. Documents that you have created or
|
||||
msgid "There was an error uploading your file. Please try again."
|
||||
msgstr "There was an error uploading your file. Please try again."
|
||||
|
||||
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
|
||||
msgid "There was an issue rendering some fields, please review the fields and try again."
|
||||
msgstr "There was an issue rendering some fields, please review the fields and try again."
|
||||
|
||||
#: packages/ui/components/document/document-global-auth-action-select.tsx
|
||||
msgid "These can be overriden by setting the authentication requirements directly on each recipient in the next step. Multiple methods can be selected."
|
||||
msgstr "These can be overriden by setting the authentication requirements directly on each recipient in the next step. Multiple methods can be selected."
|
||||
@ -8724,10 +8722,6 @@ msgstr "This envelope could not be distributed at this time. Please try again."
|
||||
msgid "This envelope could not be resent at this time. Please try again."
|
||||
msgstr "This envelope could not be resent at this time. Please try again."
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
|
||||
msgid "This feature is coming soon"
|
||||
msgstr "This feature is coming soon"
|
||||
|
||||
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
@ -9199,6 +9193,7 @@ msgstr "Untitled Group"
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
msgid "Update"
|
||||
@ -9235,6 +9230,7 @@ msgstr "Update organisation"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Update organisation member"
|
||||
msgstr "Update organisation member"
|
||||
|
||||
@ -9255,9 +9251,11 @@ msgstr "Update profile"
|
||||
msgid "Update Recipient"
|
||||
msgstr "Update Recipient"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/components/tables/team-members-table.tsx
|
||||
#: apps/remix/app/components/tables/team-groups-table.tsx
|
||||
#: apps/remix/app/components/tables/organisation-members-table.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Update role"
|
||||
msgstr "Update role"
|
||||
|
||||
@ -9296,6 +9294,10 @@ msgstr "Update user"
|
||||
msgid "Update webhook"
|
||||
msgstr "Update webhook"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "Updated {organisationMemberName} to {roleLabel}."
|
||||
msgstr "Updated {organisationMemberName} to {roleLabel}."
|
||||
|
||||
#: apps/remix/app/components/forms/password.tsx
|
||||
msgid "Updating password..."
|
||||
msgstr "Updating password..."
|
||||
@ -9366,6 +9368,10 @@ msgstr "Upload Document"
|
||||
msgid "Upload documents and add recipients"
|
||||
msgstr "Upload documents and add recipients"
|
||||
|
||||
#: packages/ui/primitives/document-upload.tsx
|
||||
msgid "Upload Envelope"
|
||||
msgstr "Upload Envelope"
|
||||
|
||||
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
|
||||
#: apps/remix/app/components/general/document/envelope-upload-button.tsx
|
||||
@ -9662,10 +9668,6 @@ msgstr "View more"
|
||||
msgid "View next document"
|
||||
msgstr "View next document"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "View Original Document"
|
||||
msgstr "View Original Document"
|
||||
|
||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||
msgid "View owner"
|
||||
msgstr "View owner"
|
||||
@ -9780,10 +9782,6 @@ msgstr "We are unable to update this passkey at the moment. Please try again lat
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "We couldn't create a Stripe customer. Please try again."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't promote the member to owner. Please try again."
|
||||
msgstr "We couldn't promote the member to owner. Please try again."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
|
||||
msgid "We couldn't update the group. Please try again."
|
||||
msgstr "We couldn't update the group. Please try again."
|
||||
@ -9946,6 +9944,7 @@ msgid "We encountered an unknown error while attempting to update the template.
|
||||
msgstr "We encountered an unknown error while attempting to update the template. Please try again later."
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "We encountered an unknown error while attempting to update this organisation member. Please try again later."
|
||||
msgstr "We encountered an unknown error while attempting to update this organisation member. Please try again later."
|
||||
|
||||
@ -10018,9 +10017,10 @@ msgstr "We were unable to set your public profile to public. Please try again."
|
||||
msgid "We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again."
|
||||
msgstr "We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again."
|
||||
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
msgid "We were unable to submit this document at this time. Please try again later."
|
||||
msgstr "We were unable to submit this document at this time. Please try again later."
|
||||
@ -10322,6 +10322,7 @@ msgid "You are currently updating <0>{memberName}.</0>"
|
||||
msgstr "You are currently updating <0>{memberName}.</0>"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
msgid "You are currently updating <0>{organisationMemberName}.</0>"
|
||||
msgstr "You are currently updating <0>{organisationMemberName}.</0>"
|
||||
|
||||
@ -10495,7 +10496,7 @@ msgid "You have been invited to join the following organisation"
|
||||
msgstr "You have been invited to join the following organisation"
|
||||
|
||||
#: packages/lib/server-only/recipient/set-document-recipients.ts
|
||||
#: packages/lib/server-only/recipient/delete-document-recipient.ts
|
||||
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
|
||||
msgid "You have been removed from a document"
|
||||
msgstr "You have been removed from a document"
|
||||
|
||||
@ -10664,6 +10665,7 @@ msgstr "You need to be an admin to manage API tokens."
|
||||
msgid "You need to be logged in as <0>{email}</0> to view this page."
|
||||
msgstr "You need to be logged in as <0>{email}</0> to view this page."
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx
|
||||
msgid "You need to be logged in to view this page."
|
||||
msgstr "You need to be logged in to view this page."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
@ -74,6 +75,10 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
password: z.string().nullable().default(null),
|
||||
documentId: z.number().default(-1).optional(),
|
||||
}),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
}).array(),
|
||||
|
||||
folder: FolderSchema.pick({
|
||||
id: true,
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import TemplateDirectLinkSchema from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||
|
||||
import { ZFieldSchema } from './field';
|
||||
import { ZRecipientLiteSchema } from './recipient';
|
||||
import { ZEnvelopeFieldSchema } from './field';
|
||||
import { ZEnvelopeRecipientLiteSchema } from './recipient';
|
||||
|
||||
/**
|
||||
* The full envelope response schema.
|
||||
@ -37,11 +36,8 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
userId: true,
|
||||
teamId: true,
|
||||
folderId: true,
|
||||
templateId: true,
|
||||
}).extend({
|
||||
templateId: z
|
||||
.number()
|
||||
.nullish()
|
||||
.describe('The ID of the template that the document was created from, if any.'),
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
@ -60,29 +56,14 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
}),
|
||||
recipients: ZRecipientLiteSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
}).array(),
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
}).array(),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
fields: ZEnvelopeFieldSchema.array(),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
envelopeId: true,
|
||||
id: true,
|
||||
title: true,
|
||||
documentDataId: true,
|
||||
order: true,
|
||||
})
|
||||
.extend({
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
}).array(),
|
||||
directLink: TemplateDirectLinkSchema.pick({
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
|
||||
@ -188,7 +188,7 @@ export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;
|
||||
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),
|
||||
|
||||
@ -50,6 +50,11 @@ export const ZFieldSchema = FieldSchema.pick({
|
||||
templateId: z.number().nullish(),
|
||||
});
|
||||
|
||||
export const ZEnvelopeFieldSchema = ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
});
|
||||
|
||||
export const ZFieldPageNumberSchema = z
|
||||
.number()
|
||||
.min(1)
|
||||
@ -69,6 +74,30 @@ 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 ZClampedFieldPositionXSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The percentage based X coordinate where the field will be placed.');
|
||||
|
||||
export const ZClampedFieldPositionYSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The percentage based Y coordinate where the field will be placed.');
|
||||
|
||||
export const ZClampedFieldWidthSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The percentage based width of the field on the page.');
|
||||
|
||||
export const ZClampedFieldHeightSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The percentage based height of the field on the page.');
|
||||
|
||||
// ---------------------------------------------
|
||||
|
||||
const PrismaDecimalSchema = z.preprocess(
|
||||
|
||||
@ -95,3 +95,18 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
documentId: z.number().nullish(),
|
||||
templateId: z.number().nullish(),
|
||||
});
|
||||
|
||||
export const ZEnvelopeRecipientSchema = ZRecipientSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
});
|
||||
|
||||
export const ZEnvelopeRecipientLiteSchema = ZRecipientLiteSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
});
|
||||
|
||||
export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
@ -87,6 +88,10 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}).nullable(),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type TTemplate = z.infer<typeof ZTemplateSchema>;
|
||||
|
||||
@ -30,3 +30,5 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
|
||||
|
||||
return chacha.decrypt(dataAsBytes);
|
||||
};
|
||||
|
||||
export { sha256 };
|
||||
|
||||
@ -153,6 +153,11 @@ export const createFieldHoverInteraction = ({
|
||||
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
|
||||
|
||||
fieldGroup.on('mouseover', () => {
|
||||
const layer = fieldRect.getLayer();
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
@ -161,6 +166,11 @@ export const createFieldHoverInteraction = ({
|
||||
});
|
||||
|
||||
fieldGroup.on('mouseout', () => {
|
||||
const layer = fieldRect.getLayer();
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
@ -169,6 +179,11 @@ export const createFieldHoverInteraction = ({
|
||||
});
|
||||
|
||||
fieldGroup.on('transformstart', () => {
|
||||
const layer = fieldRect.getLayer();
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
@ -177,6 +192,11 @@ export const createFieldHoverInteraction = ({
|
||||
});
|
||||
|
||||
fieldGroup.on('transformend', () => {
|
||||
const layer = fieldRect.getLayer();
|
||||
if (!layer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
|
||||
@ -19,7 +19,7 @@ export type FieldToRender = Pick<
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
fieldMeta?: TFieldMetaSchema | null;
|
||||
signature?: Signature | null;
|
||||
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
|
||||
};
|
||||
|
||||
export type RenderFieldElementOptions = {
|
||||
|
||||
@ -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,
|
||||
@ -62,16 +63,15 @@ export const renderCheckboxFieldElement = (
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Todo: Envelopes - check sorting more than 10
|
||||
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
|
||||
const squares = fieldGroup
|
||||
.find('.checkbox-square')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
|
||||
const checkmarks = fieldGroup
|
||||
.find('.checkbox-checkmark')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
|
||||
const text = fieldGroup
|
||||
.find('.checkbox-text')
|
||||
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
|
||||
|
||||
const groupedItems = squares.map((square, i) => ({
|
||||
squareElement: square,
|
||||
@ -130,7 +130,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 +170,7 @@ export const renderCheckboxFieldElement = (
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
stroke: '#374151',
|
||||
strokeWidth: 2,
|
||||
strokeWidth: 1.5,
|
||||
cornerRadius: 2,
|
||||
fill: 'white',
|
||||
});
|
||||
|
||||
@ -8,13 +8,24 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import type { TFieldMetaSchema } from '../../types/field-meta';
|
||||
import { renderCheckboxFieldElement } from './render-checkbox-field';
|
||||
import { renderDropdownFieldElement } from './render-dropdown-field';
|
||||
import { renderGenericTextFieldElement } from './render-generic-text-field';
|
||||
import { renderRadioFieldElement } from './render-radio-field';
|
||||
import { renderSignatureFieldElement } from './render-signature-field';
|
||||
import { renderTextFieldElement } from './render-text-field';
|
||||
|
||||
export const MIN_FIELD_HEIGHT_PX = 12;
|
||||
export const MIN_FIELD_WIDTH_PX = 36;
|
||||
|
||||
/**
|
||||
* The render type.
|
||||
*
|
||||
* @default 'edit'
|
||||
*
|
||||
* - `edit` - The field is rendered in editor page.
|
||||
* - `sign` - The field is rendered for the signing page.
|
||||
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
|
||||
*/
|
||||
export type FieldRenderMode = 'edit' | 'sign' | 'export';
|
||||
|
||||
export type FieldToRender = Pick<
|
||||
Field,
|
||||
'envelopeItemId' | 'recipientId' | 'type' | 'page' | 'customText' | 'inserted' | 'recipientId'
|
||||
@ -25,7 +36,7 @@ export type FieldToRender = Pick<
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
fieldMeta?: TFieldMetaSchema | null;
|
||||
signature?: Signature | null;
|
||||
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
|
||||
};
|
||||
|
||||
type RenderFieldOptions = {
|
||||
@ -38,16 +49,7 @@ type RenderFieldOptions = {
|
||||
|
||||
translations: Record<FieldType, string> | null;
|
||||
|
||||
/**
|
||||
* The render type.
|
||||
*
|
||||
* @default 'edit'
|
||||
*
|
||||
* - `edit` - The field is rendered in edit mode.
|
||||
* - `sign` - The field is rendered in sign mode. No interactive elements.
|
||||
* - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
|
||||
*/
|
||||
mode: 'edit' | 'sign' | 'export';
|
||||
mode: FieldRenderMode;
|
||||
|
||||
scale: number;
|
||||
editable?: boolean;
|
||||
@ -76,10 +78,21 @@ export const renderField = ({
|
||||
};
|
||||
|
||||
return match(field.type)
|
||||
.with(FieldType.TEXT, () => renderTextFieldElement(field, options))
|
||||
.with(
|
||||
FieldType.INITIALS,
|
||||
FieldType.NAME,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
FieldType.TEXT,
|
||||
FieldType.NUMBER,
|
||||
() => renderGenericTextFieldElement(field, options),
|
||||
)
|
||||
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
|
||||
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
|
||||
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
|
||||
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
|
||||
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
|
||||
.with(FieldType.FREE_SIGNATURE, () => {
|
||||
throw new Error('Free signature fields are not supported');
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
const DEFAULT_TEXT_ALIGN = 'left';
|
||||
|
||||
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
|
||||
|
||||
@ -31,8 +33,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
// Calculate text positioning based on alignment
|
||||
const textX = 0;
|
||||
const textY = 0;
|
||||
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
|
||||
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
|
||||
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || DEFAULT_TEXT_ALIGN;
|
||||
const textVerticalAlign: 'top' | 'middle' | 'bottom' = 'middle';
|
||||
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
const textPadding = 10;
|
||||
|
||||
@ -40,51 +42,29 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
|
||||
// Handle edit mode.
|
||||
if (mode === 'edit') {
|
||||
textToRender = fieldTypeName;
|
||||
textAlign = 'center';
|
||||
textVerticalAlign = 'middle';
|
||||
|
||||
if (textMeta?.label) {
|
||||
textToRender = textMeta.label;
|
||||
} else if (textMeta?.text) {
|
||||
if (textMeta?.text) {
|
||||
textToRender = textMeta.text;
|
||||
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
if (textMeta.characterLimit) {
|
||||
textToRender = textToRender.slice(0, textMeta.characterLimit);
|
||||
}
|
||||
} else {
|
||||
// Show field name which is centered for the edit mode if no label/text is avaliable.
|
||||
textToRender = textMeta?.label || fieldTypeName;
|
||||
textAlign = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sign mode.
|
||||
if (mode === 'sign' || mode === 'export') {
|
||||
textToRender = fieldTypeName;
|
||||
textAlign = 'center';
|
||||
textVerticalAlign = 'middle';
|
||||
|
||||
if (textMeta?.label) {
|
||||
textToRender = textMeta.label;
|
||||
}
|
||||
|
||||
if (textMeta?.text) {
|
||||
textToRender = textMeta.text;
|
||||
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
if (textMeta.characterLimit) {
|
||||
textToRender = textToRender.slice(0, textMeta.characterLimit);
|
||||
if (!field.inserted) {
|
||||
if (textMeta?.text) {
|
||||
textToRender = textMeta.text;
|
||||
} else if (mode === 'sign') {
|
||||
// Only show the field name in sign mode if no text/label is avaliable.
|
||||
textToRender = textMeta?.label || fieldTypeName;
|
||||
textAlign = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
if (field.inserted) {
|
||||
textToRender = field.customText;
|
||||
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
if (textMeta?.characterLimit) {
|
||||
textToRender = textToRender.slice(0, textMeta.characterLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,7 +86,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
return fieldText;
|
||||
};
|
||||
|
||||
export const renderTextFieldElement = (
|
||||
export const renderGenericTextFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
@ -159,7 +159,7 @@ export const renderRadioFieldElement = (
|
||||
y: itemInputY,
|
||||
radius: calculateRadioSize(fontSize) / 2,
|
||||
stroke: '#374151',
|
||||
strokeWidth: 2,
|
||||
strokeWidth: 1.5,
|
||||
fill: 'white',
|
||||
});
|
||||
|
||||
|
||||
@ -7,7 +7,13 @@ export type GetFileOptions = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const getFile = async ({ type, data }: GetFileOptions) => {
|
||||
/**
|
||||
* KEPT FOR POSTERITY, SHOULD BE REMOVED IN THE FUTURE
|
||||
* DO NOT USE OR I WILL FIRE YOU
|
||||
*
|
||||
* - Lucas, 2025-11-04
|
||||
*/
|
||||
const getFile = async ({ type, data }: GetFileOptions) => {
|
||||
return await match(type)
|
||||
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
|
||||
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))
|
||||
|
||||
@ -7,6 +7,7 @@ import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
|
||||
import { uploadS3File } from './server-actions';
|
||||
|
||||
type File = {
|
||||
@ -43,6 +44,28 @@ export const putPdfFileServerSide = async (file: File) => {
|
||||
return await createDocumentData({ type, data });
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a pdf file and normalizes it.
|
||||
*/
|
||||
export const putNormalizedPdfFileServerSide = async (file: File) => {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const normalized = await normalizePdf(buffer);
|
||||
|
||||
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
|
||||
|
||||
const documentData = await putFileServerSide({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalized),
|
||||
});
|
||||
|
||||
return await createDocumentData({
|
||||
type: documentData.type,
|
||||
data: documentData.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the appropriate storage location.
|
||||
*/
|
||||
|
||||
@ -6,7 +6,7 @@ import { env } from '@documenso/lib/utils/env';
|
||||
import type {
|
||||
TGetPresignedPostUrlResponse,
|
||||
TUploadPdfResponse,
|
||||
} from '@documenso/remix/server/api/files.types';
|
||||
} from '@documenso/remix/server/api/files/files.types';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
34
packages/lib/utils/envelope-download.ts
Normal file
34
packages/lib/utils/envelope-download.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { EnvelopeItem } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export type EnvelopeItemPdfUrlOptions =
|
||||
| {
|
||||
type: 'download';
|
||||
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
token: string | undefined;
|
||||
version: 'original' | 'signed';
|
||||
}
|
||||
| {
|
||||
type: 'view';
|
||||
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
token: string | undefined;
|
||||
};
|
||||
|
||||
export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
|
||||
const { envelopeItem, token, type } = options;
|
||||
|
||||
const { id, envelopeId } = envelopeItem;
|
||||
|
||||
if (type === 'download') {
|
||||
const version = options.version;
|
||||
|
||||
return token
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`;
|
||||
}
|
||||
|
||||
return token
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
|
||||
};
|
||||
@ -104,7 +104,6 @@ export const extractFieldInsertionValues = ({
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
|
||||
|
||||
// Todo
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid number',
|
||||
@ -127,7 +126,6 @@ export const extractFieldInsertionValues = ({
|
||||
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
|
||||
|
||||
// Todo
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid email',
|
||||
@ -189,7 +187,6 @@ export const extractFieldInsertionValues = ({
|
||||
(sign) => sign.label === validationRule,
|
||||
);
|
||||
|
||||
// Todo: Envelopes - Test this.
|
||||
if (checkboxValidationRule) {
|
||||
const isValid = validateCheckboxLength(
|
||||
selectedValues.length,
|
||||
@ -224,7 +221,6 @@ export const extractFieldInsertionValues = ({
|
||||
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
|
||||
|
||||
// Todo: Envelopes
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid dropdown value',
|
||||
|
||||
@ -81,6 +81,10 @@ export const mapFieldToLegacyField = (
|
||||
};
|
||||
|
||||
export const parseCheckboxCustomText = (customText: string): number[] => {
|
||||
if (!customText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(customText);
|
||||
};
|
||||
|
||||
|
||||
37
packages/lib/utils/is-valid-return-to.ts
Normal file
37
packages/lib/utils/is-valid-return-to.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
export const isValidReturnTo = (returnTo?: string) => {
|
||||
if (!returnTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode if it's URL encoded
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeReturnTo = (returnTo?: string) => {
|
||||
if (!returnTo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode if it's URL encoded
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@ -21,14 +21,14 @@
|
||||
"seed": "tsx ./seed-database.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"kysely": "0.26.3",
|
||||
"prisma": "^6.8.2",
|
||||
"prisma": "^6.18.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"prisma-json-types-generator": "^3.2.2",
|
||||
"prisma-json-types-generator": "^3.6.2",
|
||||
"ts-pattern": "^5.0.6",
|
||||
"zod-prisma-types": "3.2.4"
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.5.0",
|
||||
|
||||
@ -134,8 +134,8 @@ model Passkey {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
credentialId Bytes
|
||||
credentialPublicKey Bytes
|
||||
credentialId Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
|
||||
credentialPublicKey Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
|
||||
counter BigInt
|
||||
credentialDeviceType String
|
||||
credentialBackedUp Boolean
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client';
|
||||
import SuperJSON from 'superjson';
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpLink,
|
||||
isNonJsonSerializable,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
import type { AppRouter } from '../server/router';
|
||||
import { dataTransformer } from '../utils/data-transformer';
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.context.skipBatch === true,
|
||||
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
|
||||
true: httpLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: SuperJSON,
|
||||
transformer: dataTransformer,
|
||||
headers: (opts) => {
|
||||
if (typeof opts.op.context.teamId === 'string') {
|
||||
return {
|
||||
@ -24,7 +30,7 @@ export const trpc = createTRPCClient<AppRouter>({
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: SuperJSON,
|
||||
transformer: dataTransformer,
|
||||
headers: (opts) => {
|
||||
const operationWithTeamId = opts.opList.find(
|
||||
(op) => op.context.teamId && typeof op.context.teamId === 'string',
|
||||
|
||||
@ -12,15 +12,21 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@tanstack/react-query": "5.59.15",
|
||||
"@trpc/client": "11.0.0-rc.648",
|
||||
"@trpc/react-query": "11.0.0-rc.648",
|
||||
"@trpc/server": "11.0.0-rc.648",
|
||||
"@ts-rest/core": "^3.30.5",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@trpc/client": "11.7.0",
|
||||
"@trpc/react-query": "11.7.0",
|
||||
"@trpc/server": "11.7.0",
|
||||
"@ts-rest/core": "^3.52.0",
|
||||
"formidable": "^3.5.4",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"trpc-to-openapi": "2.0.4",
|
||||
"superjson": "^2.2.5",
|
||||
"trpc-to-openapi": "2.4.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"zod": "^3.25.76",
|
||||
"zod-form-data": "^2.0.8",
|
||||
"zod-openapi": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
|
||||
import { httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from '@trpc/client';
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import SuperJSON from 'superjson';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
import type { AppRouter } from '../server/router';
|
||||
import { dataTransformer } from '../utils/data-transformer';
|
||||
|
||||
export { getQueryKey } from '@trpc/react-query';
|
||||
|
||||
@ -44,16 +44,16 @@ export function TrpcProvider({ children, headers }: TrpcProviderProps) {
|
||||
trpc.createClient({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.context.skipBatch === true,
|
||||
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
|
||||
true: httpLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers,
|
||||
transformer: SuperJSON,
|
||||
transformer: dataTransformer,
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers,
|
||||
transformer: SuperJSON,
|
||||
transformer: dataTransformer,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@ -24,6 +24,7 @@ export const createTrpcContext = async ({
|
||||
const { session, user } = await getOptionalSession(c);
|
||||
|
||||
const req = c.req.raw;
|
||||
const res = c.res;
|
||||
|
||||
const requestMetadata = c.get('context').requestMetadata;
|
||||
|
||||
@ -54,6 +55,7 @@ export const createTrpcContext = async ({
|
||||
user: null,
|
||||
teamId,
|
||||
req,
|
||||
res,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
@ -64,6 +66,7 @@ export const createTrpcContext = async ({
|
||||
user,
|
||||
teamId,
|
||||
req,
|
||||
res,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
@ -80,6 +83,7 @@ export type TrpcContext = (
|
||||
) & {
|
||||
teamId: number | undefined;
|
||||
req: Request;
|
||||
res: Response;
|
||||
metadata: ApiRequestMetadata;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
136
packages/trpc/server/document-router/create-document-formdata.ts
Normal file
136
packages/trpc/server/document-router/create-document-formdata.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentFormDataRequestSchema,
|
||||
ZCreateDocumentFormDataResponseSchema,
|
||||
createDocumentFormDataMeta,
|
||||
} from './create-document-formdata.types';
|
||||
|
||||
/**
|
||||
* Temporary endpoint for V2 Beta until we allow passthrough documents on create.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const createDocumentFormDataRoute = authenticatedProcedure
|
||||
.meta(createDocumentFormDataMeta)
|
||||
.input(ZCreateDocumentFormDataRequestSchema)
|
||||
.output(ZCreateDocumentFormDataResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
|
||||
const { payload, file } = input;
|
||||
|
||||
const {
|
||||
title,
|
||||
externalId,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients,
|
||||
meta,
|
||||
folderId,
|
||||
attachments,
|
||||
} = payload;
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have reached your document limit for this month. Please upgrade your plan.',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const documentData = await putPdfFileServerSide(file);
|
||||
|
||||
const createdEnvelope = await createEnvelope({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
normalizePdf: false, // Not normalizing because of presigned URL.
|
||||
internalVersion: 1,
|
||||
data: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title,
|
||||
externalId,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients: (recipients || []).map((recipient) => ({
|
||||
...recipient,
|
||||
fields: (recipient.fields || []).map((field) => ({
|
||||
...field,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
documentDataId: documentData.id,
|
||||
})),
|
||||
})),
|
||||
folderId,
|
||||
envelopeItems: [
|
||||
{
|
||||
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
|
||||
documentDataId: documentData.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
attachments,
|
||||
meta: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings ?? undefined,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
const envelopeItems = await prisma.envelopeItem.findMany({
|
||||
where: {
|
||||
envelopeId: createdEnvelope.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(createdEnvelope.secondaryId);
|
||||
|
||||
const firstDocumentData = envelopeItems[0].documentData;
|
||||
|
||||
if (!firstDocumentData) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
...createdEnvelope,
|
||||
envelopeId: createdEnvelope.id,
|
||||
documentDataId: firstDocumentData.id,
|
||||
documentData: {
|
||||
...firstDocumentData,
|
||||
envelopeItemId: envelopeItems[0].id,
|
||||
},
|
||||
documentMeta: {
|
||||
...createdEnvelope.documentMeta,
|
||||
documentId: legacyDocumentId,
|
||||
},
|
||||
id: legacyDocumentId,
|
||||
fields: createdEnvelope.fields.map((field) => ({
|
||||
...field,
|
||||
documentId: legacyDocumentId,
|
||||
templateId: null,
|
||||
})),
|
||||
recipients: createdEnvelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
documentId: legacyDocumentId,
|
||||
templateId: null,
|
||||
})),
|
||||
},
|
||||
folder: createdEnvelope.folder, // Todo: Remove this prior to api-v2 release.
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { ZDocumentSchema } from '@documenso/lib/types/document';
|
||||
import {
|
||||
ZDocumentAccessAuthTypesSchema,
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldPageXSchema,
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
import {
|
||||
ZDocumentExternalIdSchema,
|
||||
ZDocumentTitleSchema,
|
||||
ZDocumentVisibilitySchema,
|
||||
} from './schema';
|
||||
|
||||
export const createDocumentFormDataMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/create/formdata',
|
||||
contentTypes: ['multipart/form-data'],
|
||||
summary: 'Create document',
|
||||
description: 'Create a document using form data.',
|
||||
tags: ['Document'],
|
||||
},
|
||||
};
|
||||
|
||||
const ZCreateDocumentFormDataPayloadRequestSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
externalId: ZDocumentExternalIdSchema.optional(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
formValues: ZDocumentFormValuesSchema.optional(),
|
||||
folderId: z
|
||||
.string()
|
||||
.describe(
|
||||
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
|
||||
)
|
||||
.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
.optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
meta: ZDocumentMetaCreateSchema.optional(),
|
||||
});
|
||||
|
||||
// !: Can't use zfd.formData() here because it receives `undefined`
|
||||
// !: somewhere in the pipeline of our openapi schema generation and throws
|
||||
// !: an error.
|
||||
export const ZCreateDocumentFormDataRequestSchema = zodFormData({
|
||||
payload: zfd.json(ZCreateDocumentFormDataPayloadRequestSchema),
|
||||
file: zfd.file(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFormDataResponseSchema = z.object({
|
||||
document: ZDocumentSchema,
|
||||
});
|
||||
|
||||
export type TCreateDocumentFormDataRequest = z.infer<typeof ZCreateDocumentFormDataRequestSchema>;
|
||||
export type TCreateDocumentFormDataResponse = z.infer<typeof ZCreateDocumentFormDataResponseSchema>;
|
||||
@ -3,20 +3,28 @@ import { EnvelopeType } from '@prisma/client';
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentRequestSchema,
|
||||
ZCreateDocumentResponseSchema,
|
||||
createDocumentMeta,
|
||||
} from './create-document.types';
|
||||
|
||||
export const createDocumentRoute = authenticatedProcedure
|
||||
.input(ZCreateDocumentRequestSchema) // Note: Before releasing this to public, update the response schema to be correct.
|
||||
.meta(createDocumentMeta)
|
||||
.input(ZCreateDocumentRequestSchema)
|
||||
.output(ZCreateDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
const { title, documentDataId, timezone, folderId, attachments } = input;
|
||||
|
||||
const { payload, file } = input;
|
||||
|
||||
const { title, timezone, folderId, attachments } = payload;
|
||||
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@ -55,6 +63,7 @@ export const createDocumentRoute = authenticatedProcedure
|
||||
});
|
||||
|
||||
return {
|
||||
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
envelopeId: document.id,
|
||||
id: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,23 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
import { ZDocumentTitleSchema } from './schema';
|
||||
|
||||
// Currently not in use until we allow passthrough documents on create.
|
||||
// export const createDocumentMeta: TrpcRouteMeta = {
|
||||
// openapi: {
|
||||
// method: 'POST',
|
||||
// path: '/document/create',
|
||||
// summary: 'Create document',
|
||||
// tags: ['Document'],
|
||||
// },
|
||||
// };
|
||||
export const createDocumentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/create',
|
||||
contentTypes: ['multipart/form-data'],
|
||||
summary: 'Create document',
|
||||
description: 'Create a document using form data.',
|
||||
tags: ['Document'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateDocumentRequestSchema = z.object({
|
||||
export const ZCreateDocumentPayloadSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string().min(1),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
|
||||
attachments: z
|
||||
@ -31,9 +34,16 @@ export const ZCreateDocumentRequestSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentResponseSchema = z.object({
|
||||
legacyDocumentId: z.number(),
|
||||
export const ZCreateDocumentRequestSchema = zodFormData({
|
||||
payload: zfd.json(ZCreateDocumentPayloadSchema),
|
||||
file: zfd.file(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentResponseSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;
|
||||
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
|
||||
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { DocumentDataType, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDownloadDocumentRequestSchema,
|
||||
ZDownloadDocumentResponseSchema,
|
||||
downloadDocumentMeta,
|
||||
} from './download-document-beta.types';
|
||||
|
||||
export const downloadDocumentBetaRoute = authenticatedProcedure
|
||||
.meta(downloadDocumentMeta)
|
||||
.input(ZDownloadDocumentRequestSchema)
|
||||
.output(ZDownloadDocumentResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { documentId, version } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
version,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// This error is done AFTER the get envelope so we can test access controls without S3.
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document downloads are only available when S3 storage is configured.',
|
||||
});
|
||||
}
|
||||
|
||||
const documentData: DocumentData | undefined = envelope.envelopeItems[0]?.documentData;
|
||||
|
||||
if (envelope.envelopeItems.length !== 1 || !documentData) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'This endpoint only supports documents with a single item. Use envelopes API instead.',
|
||||
});
|
||||
}
|
||||
|
||||
if (documentData.type !== DocumentDataType.S3_PATH) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not stored in S3 and cannot be downloaded via URL.',
|
||||
});
|
||||
}
|
||||
|
||||
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not completed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data =
|
||||
version === 'original' ? documentData.initialData || documentData.data : documentData.data;
|
||||
|
||||
const { url } = await getPresignGetUrl(data);
|
||||
|
||||
const baseTitle = envelope.title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
|
||||
return {
|
||||
downloadUrl: url,
|
||||
filename,
|
||||
contentType: 'application/pdf',
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.logger.error({
|
||||
error,
|
||||
message: 'Failed to generate download URL',
|
||||
documentId,
|
||||
version,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to generate download URL',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const downloadDocumentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/document/{documentId}/download-beta',
|
||||
summary: 'Download document (beta)',
|
||||
description: 'Get a pre-signed download URL for the original or signed version of a document',
|
||||
tags: ['Document'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDownloadDocumentRequestSchema = z.object({
|
||||
documentId: z.number().describe('The ID of the document to download.'),
|
||||
version: z
|
||||
.enum(['original', 'signed'])
|
||||
.describe(
|
||||
'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
|
||||
)
|
||||
.default('signed'),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentResponseSchema = z.object({
|
||||
downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'),
|
||||
filename: z.string().describe('The filename of the PDF file'),
|
||||
contentType: z.string().describe('MIME type of the file'),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentRequest = z.infer<typeof ZDownloadDocumentRequestSchema>;
|
||||
export type TDownloadDocumentResponse = z.infer<typeof ZDownloadDocumentResponseSchema>;
|
||||
@ -1,11 +1,3 @@
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { DocumentDataType, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDownloadDocumentRequestSchema,
|
||||
@ -17,8 +9,7 @@ export const downloadDocumentRoute = authenticatedProcedure
|
||||
.meta(downloadDocumentMeta)
|
||||
.input(ZDownloadDocumentRequestSchema)
|
||||
.output(ZDownloadDocumentResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
.query(({ input, ctx }) => {
|
||||
const { documentId, version } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
@ -28,69 +19,5 @@ export const downloadDocumentRoute = authenticatedProcedure
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// This error is done AFTER the get envelope so we can test access controls without S3.
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document downloads are only available when S3 storage is configured.',
|
||||
});
|
||||
}
|
||||
|
||||
const documentData: DocumentData | undefined = envelope.envelopeItems[0]?.documentData;
|
||||
|
||||
if (envelope.envelopeItems.length !== 1 || !documentData) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'This endpoint only supports documents with a single item. Use envelopes API instead.',
|
||||
});
|
||||
}
|
||||
|
||||
if (documentData.type !== DocumentDataType.S3_PATH) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not stored in S3 and cannot be downloaded via URL.',
|
||||
});
|
||||
}
|
||||
|
||||
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not completed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data =
|
||||
version === 'original' ? documentData.initialData || documentData.data : documentData.data;
|
||||
|
||||
const { url } = await getPresignGetUrl(data);
|
||||
|
||||
const baseTitle = envelope.title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
|
||||
return {
|
||||
downloadUrl: url,
|
||||
filename,
|
||||
contentType: 'application/pdf',
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.logger.error({
|
||||
error,
|
||||
message: 'Failed to generate download URL',
|
||||
documentId,
|
||||
version,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to generate download URL',
|
||||
});
|
||||
}
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
|
||||
@ -5,10 +5,12 @@ import type { TrpcRouteMeta } from '../trpc';
|
||||
export const downloadDocumentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/document/{documentId}/download-beta',
|
||||
summary: 'Download document (beta)',
|
||||
description: 'Get a pre-signed download URL for the original or signed version of a document',
|
||||
path: '/document/{documentId}/download',
|
||||
summary: 'Download document',
|
||||
tags: ['Document'],
|
||||
responseHeaders: z.object({
|
||||
'Content-Type': z.literal('application/pdf'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -22,11 +24,7 @@ export const ZDownloadDocumentRequestSchema = z.object({
|
||||
.default('signed'),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentResponseSchema = z.object({
|
||||
downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'),
|
||||
filename: z.string().describe('The filename of the PDF file'),
|
||||
contentType: z.string().describe('MIME type of the file'),
|
||||
});
|
||||
export const ZDownloadDocumentResponseSchema = z.instanceof(Uint8Array);
|
||||
|
||||
export type TDownloadDocumentRequest = z.infer<typeof ZDownloadDocumentRequestSchema>;
|
||||
export type TDownloadDocumentResponse = z.infer<typeof ZDownloadDocumentResponseSchema>;
|
||||
|
||||
@ -92,6 +92,14 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
title: true,
|
||||
order: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
|
||||
@ -5,11 +5,13 @@ import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { createDocumentRoute } from './create-document';
|
||||
import { createDocumentFormDataRoute } from './create-document-formdata';
|
||||
import { createDocumentTemporaryRoute } from './create-document-temporary';
|
||||
import { deleteDocumentRoute } from './delete-document';
|
||||
import { distributeDocumentRoute } from './distribute-document';
|
||||
import { downloadDocumentRoute } from './download-document';
|
||||
import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs';
|
||||
import { downloadDocumentBetaRoute } from './download-document-beta';
|
||||
import { downloadDocumentCertificateRoute } from './download-document-certificate';
|
||||
import { duplicateDocumentRoute } from './duplicate-document';
|
||||
import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
|
||||
@ -38,8 +40,10 @@ export const documentRouter = router({
|
||||
share: shareDocumentRoute,
|
||||
|
||||
// Temporary v2 beta routes to be removed once V2 is fully released.
|
||||
downloadBeta: downloadDocumentBetaRoute,
|
||||
download: downloadDocumentRoute,
|
||||
createDocumentTemporary: createDocumentTemporaryRoute,
|
||||
createDocumentFormData: createDocumentFormDataRoute,
|
||||
|
||||
// Internal document routes for custom frontend requests.
|
||||
getDocumentByToken: getDocumentByTokenRoute,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { router } from '../trpc';
|
||||
import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature';
|
||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||
@ -15,6 +14,6 @@ export const embeddingPresignRouter = router({
|
||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
||||
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
||||
applyMultiSignSignature: applyMultiSignSignatureRoute,
|
||||
// applyMultiSignSignature: applyMultiSignSignatureRoute,
|
||||
getMultiSignDocument: getMultiSignDocumentRoute,
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
|
||||
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
|
||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||
|
||||
@ -40,6 +41,10 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
||||
signature: SignatureSchema.nullable(),
|
||||
}),
|
||||
),
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type TGetMultiSignDocumentRequestSchema = z.infer<typeof ZGetMultiSignDocumentRequestSchema>;
|
||||
|
||||
@ -4,18 +4,11 @@ import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZCreateAttachmentRequestSchema,
|
||||
ZCreateAttachmentResponseSchema,
|
||||
createAttachmentMeta,
|
||||
} from './create-attachment.types';
|
||||
|
||||
export const createAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/create',
|
||||
summary: 'Create attachment',
|
||||
description: 'Create a new attachment for an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.meta(createAttachmentMeta)
|
||||
.input(ZCreateAttachmentRequestSchema)
|
||||
.output(ZCreateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const createAttachmentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/create',
|
||||
summary: 'Create attachment',
|
||||
description: 'Create a new attachment for an envelope',
|
||||
tags: ['Envelope Attachments'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateAttachmentRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: z.object({
|
||||
|
||||
@ -4,18 +4,11 @@ import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteAttachmentRequestSchema,
|
||||
ZDeleteAttachmentResponseSchema,
|
||||
deleteAttachmentMeta,
|
||||
} from './delete-attachment.types';
|
||||
|
||||
export const deleteAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/delete',
|
||||
summary: 'Delete attachment',
|
||||
description: 'Delete an attachment from an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.meta(deleteAttachmentMeta)
|
||||
.input(ZDeleteAttachmentRequestSchema)
|
||||
.output(ZDeleteAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const deleteAttachmentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/delete',
|
||||
summary: 'Delete attachment',
|
||||
description: 'Delete an attachment from an envelope',
|
||||
tags: ['Envelope Attachments'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDeleteAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
@ -2,22 +2,15 @@ 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,
|
||||
findAttachmentsMeta,
|
||||
} from './find-attachments.types';
|
||||
|
||||
export const findAttachmentsRoute = procedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/attachment',
|
||||
summary: 'Find attachments',
|
||||
description: 'Find all attachments for an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
export const findAttachmentsRoute = maybeAuthenticatedProcedure
|
||||
.meta(findAttachmentsMeta)
|
||||
.input(ZFindAttachmentsRequestSchema)
|
||||
.output(ZFindAttachmentsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@ -2,6 +2,18 @@ import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const findAttachmentsMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/attachment',
|
||||
summary: 'Find attachments',
|
||||
description: 'Find all attachments for an envelope',
|
||||
tags: ['Envelope Attachments'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZFindAttachmentsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
token: z.string().optional(),
|
||||
|
||||
@ -4,18 +4,11 @@ import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZUpdateAttachmentRequestSchema,
|
||||
ZUpdateAttachmentResponseSchema,
|
||||
updateAttachmentMeta,
|
||||
} from './update-attachment.types';
|
||||
|
||||
export const updateAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/update',
|
||||
summary: 'Update attachment',
|
||||
description: 'Update an existing attachment',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.meta(updateAttachmentMeta)
|
||||
.input(ZUpdateAttachmentRequestSchema)
|
||||
.output(ZUpdateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const updateAttachmentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/update',
|
||||
summary: 'Update attachment',
|
||||
description: 'Update an existing attachment',
|
||||
tags: ['Envelope Attachments'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
|
||||
@ -2,6 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -10,14 +11,17 @@ import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEnvelopeItemsRequestSchema,
|
||||
ZCreateEnvelopeItemsResponseSchema,
|
||||
createEnvelopeItemsMeta,
|
||||
} from './create-envelope-items.types';
|
||||
|
||||
export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
.meta(createEnvelopeItemsMeta)
|
||||
.input(ZCreateEnvelopeItemsRequestSchema)
|
||||
.output(ZCreateEnvelopeItemsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId, metadata } = ctx;
|
||||
const { envelopeId, items } = input;
|
||||
const { payload, files } = input;
|
||||
const { envelopeId } = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@ -71,7 +75,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
const organisationClaim = envelope.team.organisation.organisationClaim;
|
||||
|
||||
const remainingEnvelopeItems =
|
||||
organisationClaim.envelopeItemCount - envelope.envelopeItems.length - items.length;
|
||||
organisationClaim.envelopeItemCount - envelope.envelopeItems.length - files.length;
|
||||
|
||||
if (remainingEnvelopeItems < 0) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
@ -80,41 +84,24 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const foundDocumentData = await prisma.documentData.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: items.map((item) => item.documentDataId),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// For each file, stream to s3 and create the document data.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
|
||||
// Check that all the document data was found.
|
||||
if (foundDocumentData.length !== items.length) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check that it doesn't already have an envelope item.
|
||||
if (foundDocumentData.some((documentData) => documentData.envelopeItem?.id)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const currentHighestOrderValue =
|
||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||
data: items.map((item) => ({
|
||||
data: envelopeItems.map((item) => ({
|
||||
id: prefixedId('envelope_item'),
|
||||
envelopeId,
|
||||
title: item.title,
|
||||
@ -148,6 +135,6 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
|
||||
return {
|
||||
createdEnvelopeItems: result,
|
||||
data: result,
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,38 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
|
||||
import { ZDocumentTitleSchema } from '../document-router/schema';
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const ZCreateEnvelopeItemsRequestSchema = z.object({
|
||||
export const createEnvelopeItemsMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/item/create-many',
|
||||
summary: 'Create envelope items',
|
||||
description: 'Create multiple envelope items for an envelope',
|
||||
tags: ['Envelope Items'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateEnvelopeItemsPayloadSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
items: z
|
||||
.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string(),
|
||||
})
|
||||
.array(),
|
||||
// data: z.object() // Currently not used.
|
||||
});
|
||||
|
||||
export const ZCreateEnvelopeItemsRequestSchema = zodFormData({
|
||||
payload: zfd.json(ZCreateEnvelopeItemsPayloadSchema),
|
||||
files: zfd.repeatableOfType(zfd.file()),
|
||||
});
|
||||
|
||||
export const ZCreateEnvelopeItemsResponseSchema = z.object({
|
||||
createdEnvelopeItems: EnvelopeItemSchema.pick({
|
||||
data: EnvelopeItemSchema.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
documentDataId: true,
|
||||
envelopeId: true,
|
||||
order: true,
|
||||
})
|
||||
.extend({
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type TCreateEnvelopeItemsPayload = z.infer<typeof ZCreateEnvelopeItemsPayloadSchema>;
|
||||
export type TCreateEnvelopeItemsRequest = z.infer<typeof ZCreateEnvelopeItemsRequestSchema>;
|
||||
export type TCreateEnvelopeItemsResponse = z.infer<typeof ZCreateEnvelopeItemsResponseSchema>;
|
||||
|
||||
@ -1,18 +1,24 @@
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEnvelopeRequestSchema,
|
||||
ZCreateEnvelopeResponseSchema,
|
||||
createEnvelopeMeta,
|
||||
} from './create-envelope.types';
|
||||
|
||||
export const createEnvelopeRoute = authenticatedProcedure
|
||||
.meta(createEnvelopeMeta)
|
||||
.input(ZCreateEnvelopeRequestSchema)
|
||||
.output(ZCreateEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
|
||||
const { payload, files } = input;
|
||||
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
@ -20,12 +26,12 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
formValues,
|
||||
recipients,
|
||||
folderId,
|
||||
items,
|
||||
meta,
|
||||
attachments,
|
||||
} = input;
|
||||
} = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@ -45,13 +51,62 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length > maximumEnvelopeItemCount) {
|
||||
if (files.length > maximumEnvelopeItemCount) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// For each file, stream to s3 and create the document data.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientsToCreate = recipients?.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
accessAuth: recipient.accessAuth,
|
||||
actionAuth: recipient.actionAuth,
|
||||
fields: recipient.fields?.map((field) => {
|
||||
let documentDataId: string | undefined = undefined;
|
||||
|
||||
if (typeof field.identifier === 'string') {
|
||||
documentDataId = envelopeItems.find(
|
||||
(item) => item.title === field.identifier,
|
||||
)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof field.identifier === 'number') {
|
||||
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (field.identifier === undefined) {
|
||||
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||
}
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
documentDataId,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const envelope = await createEnvelope({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
@ -60,16 +115,16 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
type,
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
recipients,
|
||||
recipients: recipientsToCreate,
|
||||
folderId,
|
||||
envelopeItems: items,
|
||||
envelopeItems,
|
||||
},
|
||||
attachments,
|
||||
meta,
|
||||
normalizePdf: true,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user