Merge branch 'main' into feat/unlink-documents-deleted-org
@@ -17,14 +17,13 @@
|
||||
"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.1",
|
||||
"@ts-rest/open-api": "^3.52.1",
|
||||
"@ts-rest/serverless": "^3.52.1",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"luxon": "^3.7.2",
|
||||
"superjson": "^2.2.5",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"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),
|
||||
|
||||
741
packages/app-tests/constants/field-alignment-pdf.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const signatureBase64Demo = `data:image/png;base64,${fs.readFileSync(
|
||||
path.join(__dirname, '../../../packages/assets/', 'logo_icon.png'),
|
||||
'base64',
|
||||
)}`;
|
||||
|
||||
const columnWidth = 19.125;
|
||||
const fullColumnWidth = 57.37499999999998;
|
||||
const rowHeight = 6.7;
|
||||
const rowPadding = 0;
|
||||
|
||||
const calculatePositionPageOne = (
|
||||
row: number,
|
||||
column: number,
|
||||
width: 'full' | 'column' = 'column',
|
||||
) => {
|
||||
const alignmentGridStartX = 31;
|
||||
const alignmentGridStartY = 19;
|
||||
|
||||
return {
|
||||
height: rowHeight,
|
||||
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||
};
|
||||
};
|
||||
|
||||
const calculatePositionPageTwo = (
|
||||
row: number,
|
||||
column: number,
|
||||
width: 'full' | 'column' = 'column',
|
||||
) => {
|
||||
const alignmentGridStartX = 31;
|
||||
const alignmentGridStartY = 16.35;
|
||||
|
||||
return {
|
||||
height: rowHeight,
|
||||
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||
};
|
||||
};
|
||||
|
||||
const calculatePositionPageThree = (
|
||||
row: number,
|
||||
column: number,
|
||||
width: 'full' | 'column' = 'column',
|
||||
rowQuantity: number = 1,
|
||||
) => {
|
||||
const alignmentGridStartX = 31;
|
||||
const alignmentGridStartY = 16.4;
|
||||
|
||||
const rowHeight = 6.8;
|
||||
|
||||
return {
|
||||
height: rowHeight * rowQuantity,
|
||||
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||
};
|
||||
};
|
||||
|
||||
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||
/**
|
||||
* Row 1 EMAIL
|
||||
*/
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'email',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 0),
|
||||
customText: 'admin@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'email',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 1),
|
||||
customText: 'admin@documenso.com',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'email',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(0, 2),
|
||||
customText: 'admin@documenso.com',
|
||||
},
|
||||
/**
|
||||
* Row 2 NAME
|
||||
*/
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'name',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(1, 0),
|
||||
customText: 'John Doe',
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'name',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(1, 1),
|
||||
customText: 'John Doe',
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'name',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(1, 2),
|
||||
customText: 'John Doe',
|
||||
},
|
||||
/**
|
||||
* Row 3 DATE
|
||||
*/
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'date',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 0),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'date',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 1),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'date',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(2, 2),
|
||||
customText: '123456789',
|
||||
},
|
||||
/**
|
||||
* Row 4 TEXT
|
||||
*/
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'text',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(3, 0),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'text',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(3, 1),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'text',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(3, 2),
|
||||
customText: '123456789',
|
||||
},
|
||||
/**
|
||||
* Row 5 NUMBER
|
||||
*/
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'number',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(4, 0),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'number',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(4, 1),
|
||||
customText: '123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'number',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(4, 2),
|
||||
customText: '123456789',
|
||||
},
|
||||
/**
|
||||
* Row 6 Initials
|
||||
*/
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
textAlign: 'left',
|
||||
type: 'initials',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(5, 0),
|
||||
customText: 'JD',
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'initials',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(5, 1),
|
||||
customText: 'JD',
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
type: 'initials',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(5, 2),
|
||||
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,
|
||||
...calculatePositionPageOne(6, 0),
|
||||
customText: '0',
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
direction: 'vertical',
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: false, value: 'Option 2' },
|
||||
],
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(6, 1),
|
||||
customText: '',
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
direction: 'horizontal',
|
||||
type: 'radio',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: true, value: 'Option 2' },
|
||||
],
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(6, 2),
|
||||
customText: '1',
|
||||
},
|
||||
/**
|
||||
* 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,
|
||||
...calculatePositionPageOne(7, 0),
|
||||
customText: toCheckboxCustomText([0]),
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
fieldMeta: {
|
||||
direction: 'vertical',
|
||||
type: 'checkbox',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: false, value: 'Option 2' },
|
||||
],
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(7, 1),
|
||||
customText: '',
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
direction: 'horizontal',
|
||||
type: 'checkbox',
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: true, value: 'Option 2' },
|
||||
],
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(7, 2),
|
||||
customText: toCheckboxCustomText([1]),
|
||||
},
|
||||
/**
|
||||
* Row 8 Dropdown
|
||||
*/
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||
type: 'dropdown',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(8, 0),
|
||||
customText: 'Option 1',
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||
type: 'dropdown',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(8, 1),
|
||||
customText: 'Option 1',
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||
type: 'dropdown',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(8, 2),
|
||||
customText: 'Option 1',
|
||||
},
|
||||
/**
|
||||
* Row 9 Signature
|
||||
*/
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
fontSize: 10,
|
||||
type: 'signature',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 0),
|
||||
customText: '',
|
||||
signature: 'My Signature',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
type: 'signature',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 1),
|
||||
customText: '',
|
||||
signature: 'My Signature',
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
fontSize: 20,
|
||||
type: 'signature',
|
||||
},
|
||||
page: 1,
|
||||
...calculatePositionPageOne(9, 2),
|
||||
customText: '',
|
||||
signature: 'My Signature',
|
||||
},
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 2
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
// TEXT GRID ROW 1
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'text',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(0, 0),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'text',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(0, 1),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'text',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(0, 2),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
// TEXT GRID ROW 2
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'text',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(1, 0),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'text',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(1, 1),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'text',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(1, 2),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
// TEXT GRID ROW 3
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'text',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(2, 0),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'text',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(2, 1),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'text',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(2, 2),
|
||||
customText: 'SOME TEXT',
|
||||
},
|
||||
// NUMBER GRID ROW 1
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'number',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(3, 0),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'number',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(3, 1),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'number',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(3, 2),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
// NUMBER GRID ROW 2
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'number',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(4, 0),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'number',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(4, 1),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'number',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(4, 2),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
// NUMBER GRID ROW 3
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'left',
|
||||
type: 'number',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(5, 0),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'center',
|
||||
type: 'number',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(5, 1),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
textAlign: 'right',
|
||||
type: 'number',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(5, 2),
|
||||
customText: '123456789123456789',
|
||||
},
|
||||
// Text combing
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
verticalAlign: 'middle',
|
||||
letterSpacing: 32,
|
||||
characterLimit: 9,
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(6, 0, 'full'),
|
||||
positionX: calculatePositionPageTwo(6, 0, 'full').positionX + 1.75,
|
||||
width: calculatePositionPageTwo(6, 0, 'full').width + 1.75,
|
||||
customText: 'HEY HEY 1',
|
||||
},
|
||||
// Number combing
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
fieldMeta: {
|
||||
type: 'number',
|
||||
verticalAlign: 'middle',
|
||||
letterSpacing: 32,
|
||||
},
|
||||
page: 2,
|
||||
...calculatePositionPageTwo(7, 0, 'full'),
|
||||
positionX: calculatePositionPageTwo(7, 0, 'full').positionX + 1.75,
|
||||
width: calculatePositionPageTwo(7, 0, 'full').width + 1.75,
|
||||
|
||||
customText: '123456789',
|
||||
},
|
||||
/**
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*
|
||||
* PAGE 2 TEXT MULTILINE
|
||||
*
|
||||
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||
*/
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
verticalAlign: 'top',
|
||||
textAlign: 'left',
|
||||
lineHeight: 2.24,
|
||||
type: 'text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePositionPageThree(0, 0, 'full', 3),
|
||||
customText:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'center',
|
||||
lineHeight: 2.24,
|
||||
type: 'text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePositionPageThree(3, 0, 'full', 3),
|
||||
customText:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
verticalAlign: 'bottom',
|
||||
textAlign: 'right',
|
||||
lineHeight: 2.24,
|
||||
type: 'text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePositionPageThree(6, 0, 'full', 3),
|
||||
customText:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
] as const;
|
||||
486
packages/app-tests/constants/field-meta-pdf.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
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';
|
||||
import { signatureBase64Demo } 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: signatureBase64Demo,
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
fieldMeta: {
|
||||
type: 'signature',
|
||||
},
|
||||
page: 2,
|
||||
...calculatePosition(1, 0),
|
||||
customText: '',
|
||||
signature: signatureBase64Demo,
|
||||
},
|
||||
{
|
||||
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 super overflow maybe',
|
||||
},
|
||||
|
||||
/**
|
||||
* PAGE 3 TEXT
|
||||
*/
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(0, 0, 'full'),
|
||||
customText: 'Hello world, this is some random text that I have written here',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(1, 0),
|
||||
customText: 'Some text that should overflow correctly',
|
||||
},
|
||||
{
|
||||
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: 'Input should have a placeholder text when clicked',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Demo Label',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(3, 1),
|
||||
customText: 'Should have a label during editing and signing',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
text: 'Prefilled text',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(3, 2),
|
||||
customText: '',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(4, 0),
|
||||
customText: 'This is a required field',
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
readOnly: true,
|
||||
text: 'Some Readonly Value',
|
||||
},
|
||||
page: 3,
|
||||
...calculatePosition(4, 1),
|
||||
customText: '',
|
||||
},
|
||||
/**
|
||||
* 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: '123456789',
|
||||
},
|
||||
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,
|
||||
value: '123456789',
|
||||
},
|
||||
page: 4,
|
||||
...calculatePosition(4, 1),
|
||||
customText: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: false, value: 'Option 2' },
|
||||
{ id: 3, checked: true, value: 'Option 3' },
|
||||
],
|
||||
},
|
||||
page: 5,
|
||||
...calculatePosition(1, 0),
|
||||
customText: '2',
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
direction: 'vertical',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
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: '2',
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
fieldMeta: {
|
||||
direction: 'vertical',
|
||||
type: 'radio',
|
||||
readOnly: true,
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: false, value: 'Option 2' },
|
||||
{ id: 3, checked: true, 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: 3, checked: false, 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: toCheckboxCustomText([2]),
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
fieldMeta: {
|
||||
direction: 'vertical',
|
||||
type: 'checkbox',
|
||||
readOnly: true,
|
||||
values: [
|
||||
{ id: 1, checked: false, value: 'Option 1' },
|
||||
{ id: 2, checked: true, 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 2',
|
||||
},
|
||||
page: 7,
|
||||
...calculatePosition(1, 0),
|
||||
customText: 'Option 2',
|
||||
},
|
||||
{
|
||||
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 3',
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
fieldMeta: {
|
||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||
type: 'dropdown',
|
||||
defaultValue: 'Option 1',
|
||||
readOnly: true,
|
||||
},
|
||||
page: 7,
|
||||
...calculatePosition(2, 1),
|
||||
customText: 'Option 1',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => {
|
||||
return {
|
||||
...field,
|
||||
};
|
||||
});
|
||||
@@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||
// Test promoting a MEMBER to owner
|
||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
|
||||
// Find and click the "Promote to owner" button for the member
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton).toBeVisible();
|
||||
await expect(promoteButton).not.toBeDisabled();
|
||||
// Find and click the "Update role" button for the member
|
||||
const updateRoleButton = memberRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await expect(updateRoleButton).toBeVisible();
|
||||
await expect(updateRoleButton).not.toBeDisabled();
|
||||
|
||||
await promoteButton.click();
|
||||
await updateRoleButton.click();
|
||||
|
||||
// Verify success toast appears
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Reload the page to see the changes
|
||||
await page.reload();
|
||||
@@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
||||
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Verify that the promote button is now disabled for the new owner
|
||||
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||
// Verify that the Update role button exists for the new owner and shows Owner as current role
|
||||
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await expect(newOwnerUpdateButton).toBeVisible();
|
||||
|
||||
// Test that we can't promote the current owner (button should be disabled)
|
||||
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
|
||||
// Verify clicking it shows the dialog with Owner already selected
|
||||
await newOwnerUpdateButton.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Close the dialog without making changes
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
||||
@@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
||||
|
||||
// Promote the manager to owner
|
||||
const managerRow = page.getByRole('row', { name: managerUser.email });
|
||||
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
|
||||
const updateRoleButton = managerRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||
await updateRoleButton.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Reload and verify the change
|
||||
await page.reload();
|
||||
@@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
|
||||
|
||||
// Promote the admin member to owner
|
||||
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
||||
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
|
||||
await promoteButton.click();
|
||||
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
const updateRoleButton = adminMemberRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
|
||||
await updateRoleButton.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Reload and verify the change
|
||||
await page.reload();
|
||||
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
@@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Promote member to owner
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
const updateRoleButton = memberRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await updateRoleButton.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Reload page to see updated state
|
||||
await page.reload();
|
||||
@@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||
memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
|
||||
// Verify the promote button is now disabled for the new owner
|
||||
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||
// Verify the Update role button exists and shows Owner as current role
|
||||
const newOwnerUpdateButton = memberRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await expect(newOwnerUpdateButton).toBeVisible();
|
||||
|
||||
// Sign in as the newly promoted user to verify they have owner permissions
|
||||
await apiSignin({
|
||||
@@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
||||
|
||||
// First promotion: Member 1 becomes owner
|
||||
let member1Row = page.getByRole('row', { name: member1User.email });
|
||||
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton1.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
let updateRoleButton1 = member1Row.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await updateRoleButton1.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Verify Member 1 is now owner and button is disabled
|
||||
// Verify Member 1 is now owner
|
||||
member1Row = page.getByRole('row', { name: member1User.email });
|
||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton1).toBeDisabled();
|
||||
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
|
||||
await expect(updateRoleButton1).toBeVisible();
|
||||
|
||||
// Second promotion: Member 2 becomes the new owner
|
||||
const member2Row = page.getByRole('row', { name: member2User.email });
|
||||
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(promoteButton2).not.toBeDisabled();
|
||||
await promoteButton2.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
const updateRoleButton2 = member2Row.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await expect(updateRoleButton2).toBeVisible();
|
||||
await updateRoleButton2.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.reload();
|
||||
|
||||
@@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
||||
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||
|
||||
// Verify Member 1's promote button is now enabled again
|
||||
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||
await expect(newPromoteButton1).not.toBeDisabled();
|
||||
// Verify Member 1's Update role button is still visible
|
||||
const newUpdateButton1 = member1Row.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await expect(newUpdateButton1).toBeVisible();
|
||||
});
|
||||
|
||||
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
||||
@@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
});
|
||||
|
||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||
await promoteButton.click();
|
||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
const updateRoleButton = memberRow.getByRole('button', {
|
||||
name: 'Update role',
|
||||
});
|
||||
await updateRoleButton.click();
|
||||
|
||||
// Wait for dialog to open and select Owner role
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Find and click the select trigger - it's a button with role="combobox"
|
||||
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||
|
||||
// Select "Owner" from the dropdown options
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
// Click Update button
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for dialog to close (indicates success)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Test that the new owner can access organisation settings
|
||||
await apiSignin({
|
||||
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 { ALIGNMENT_TEST_FIELDS } 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: ALIGNMENT_TEST_FIELDS.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(
|
||||
ALIGNMENT_TEST_FIELDS.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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -232,19 +232,19 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('user2@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 2');
|
||||
await page.locator('button[role="combobox"]').nth(1).click();
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('user3@example.com');
|
||||
await page.getByLabel('Name').nth(2).fill('User 3');
|
||||
await page.locator('button[role="combobox"]').nth(2).click();
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(3).fill('user4@example.com');
|
||||
await page.getByLabel('Name').nth(3).fill('User 4');
|
||||
await page.locator('button[role="combobox"]').nth(3).click();
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -252,8 +252,8 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 1 (user1@example.com)').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
@@ -271,8 +271,8 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 3 (user3@example.com)').click();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
@@ -574,6 +574,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
if (i > 1) {
|
||||
await page.getByText(`User ${i} (user${i}@example.com)`).click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
|
||||
@@ -85,16 +85,18 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
@@ -126,13 +128,20 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
@@ -165,11 +174,15 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
@@ -195,11 +208,15 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// Check document counts.
|
||||
@@ -227,11 +244,15 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
@@ -303,7 +324,8 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Check document counts.
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
476
packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
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 { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
|
||||
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
test.skip('seed alignment test document', async ({ page }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: 'example@documenso.com',
|
||||
},
|
||||
include: {
|
||||
ownedOrganisations: {
|
||||
include: {
|
||||
teams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = user.id;
|
||||
const teamId = user.ownedOrganisations[0].teams[0].id;
|
||||
|
||||
await seedAlignmentTestDocument({
|
||||
userId,
|
||||
teamId,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: false,
|
||||
status: DocumentStatus.DRAFT,
|
||||
});
|
||||
});
|
||||
|
||||
test('field placement visual regression', async ({ page, request }, testInfo) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
const fieldMetaFields = FIELD_META_TEST_FIELDS.map((field) => ({
|
||||
identifier: 'field-meta',
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
fieldMeta: field.fieldMeta,
|
||||
}));
|
||||
|
||||
const alignmentFields = ALIGNMENT_TEST_FIELDS.map((field) => ({
|
||||
identifier: 'alignment-pdf',
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
fieldMeta: field.fieldMeta,
|
||||
}));
|
||||
|
||||
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Envelope Full Field Test',
|
||||
recipients: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [...fieldMetaFields, ...alignmentFields],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||
|
||||
formData.append('files', new File([alignmentPdf], 'alignment-pdf', { type: 'application/pdf' }));
|
||||
formData.append('files', new File([fieldMetaPdf], 'field-meta', { type: 'application/pdf' }));
|
||||
|
||||
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createEnvelopeRequest.ok()).toBeTruthy();
|
||||
expect(createEnvelopeRequest.status()).toBe(200);
|
||||
|
||||
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: createdEnvelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipientId = envelope.recipients[0].id;
|
||||
const alignmentItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
|
||||
const fieldMetaItem = envelope.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');
|
||||
}
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
const uninsertedFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
inserted: false,
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
uninsertedFields.map(async (field) => {
|
||||
let foundField = ALIGNMENT_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
field.page === f.page &&
|
||||
field.envelopeItem.title === 'alignment-pdf' &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||
);
|
||||
|
||||
if (!foundField) {
|
||||
foundField = FIELD_META_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
field.page === f.page &&
|
||||
field.envelopeItem.title === 'field-meta' &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
if (!foundField) {
|
||||
throw new Error('Field not found');
|
||||
}
|
||||
|
||||
await prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
inserted: true,
|
||||
customText: foundField.customText,
|
||||
signature: foundField.signature
|
||||
? {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
signatureImageAsBase64: isBase64Image(foundField.signature)
|
||||
? foundField.signature
|
||||
: null,
|
||||
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
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: recipientToken,
|
||||
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 canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await canvas.encode('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).toBeLessThan(2);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
@@ -51,6 +57,9 @@ test.describe('Signing Certificate Tests', () => {
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click({ force: true });
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
@@ -78,10 +87,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 +135,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 +192,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 +240,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 +297,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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
@@ -81,20 +82,23 @@ test('[TEAMS]: can create a document inside a document folder', async ({ page })
|
||||
redirectPath: `/t/${team.url}/documents/f/${teamFolder.id}`,
|
||||
});
|
||||
|
||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||
await fileInput.waitFor({ state: 'attached' });
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await fileInput.setInputFiles(
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||
});
|
||||
|
||||
test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
||||
@@ -368,7 +372,7 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
|
||||
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'New Template' }).click();
|
||||
await page.getByRole('button', { name: 'Template (Legacy)' }).click();
|
||||
|
||||
await page.getByText('Upload Template Document').click();
|
||||
|
||||
@@ -382,11 +386,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Expect redirect.
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||
|
||||
// Return to folder and verify file is visible.
|
||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||
});
|
||||
|
||||
test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
||||
@@ -842,7 +846,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByRole('button', { name: 'Upload Document' }).click(),
|
||||
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(
|
||||
@@ -851,7 +855,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'Admins only' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -87,9 +87,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
|
||||
// Wait for the update to complete
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
@@ -154,9 +152,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
|
||||
// Wait for finish
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
const template = await seedTeamTemplateWithMeta(team);
|
||||
|
||||
|
||||
@@ -97,9 +97,10 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(3);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT', 'SIGNATURE'].toSorted(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -237,10 +238,9 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedFields.fields;
|
||||
|
||||
expect(fields.length).toBe(4);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields[2].type).toBe('SIGNATURE');
|
||||
expect(fields[3].type).toBe('SIGNATURE');
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT', 'SIGNATURE', 'SIGNATURE'].toSorted(),
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -292,8 +292,9 @@ test.describe('AutoSave Fields Step', () => {
|
||||
const fields = retrievedTemplate.fields;
|
||||
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields[0].type).toBe('SIGNATURE');
|
||||
expect(fields[1].type).toBe('TEXT');
|
||||
expect(fields.map((field) => field.type).toSorted()).toEqual(
|
||||
['SIGNATURE', 'TEXT'].toSorted(),
|
||||
);
|
||||
|
||||
const textField = fields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -121,7 +124,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
|
||||
await expect(page.getByText('404 not found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
|
||||
test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const directTemplateWithAuth = await seedDirectTemplate({
|
||||
@@ -153,6 +156,53 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const directTemplateWithAuth = await seedDirectTemplate({
|
||||
title: 'Personal direct template link',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
createTemplateOptions: {
|
||||
authOptions: createDocumentAuthOptions({
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
globalActionAuth: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const directTemplatePath = formatDirectTemplatePath(
|
||||
directTemplateWithAuth.directLink?.token || '',
|
||||
);
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
await expect(page.getByText('Authentication required')).toBeVisible();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await page.goto(directTemplatePath);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
|
||||
@@ -175,6 +225,9 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
@@ -183,3 +236,173 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
// Add a longer waiting period to ensure document status is updated
|
||||
await page.waitForTimeout(3000);
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'Team direct template link 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: template.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
// Add another signer
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
signingOrder: 2,
|
||||
envelopeId: template.id,
|
||||
email: originalSecondSignerEmail,
|
||||
name: originalName,
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
});
|
||||
|
||||
// Check that the direct template link is accessible.
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
|
||||
expect(nextRecipientNameInputValue).toBe(originalName);
|
||||
|
||||
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
|
||||
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
|
||||
|
||||
const newName = 'Hello';
|
||||
const newSecondSignerEmail = seedTestEmail();
|
||||
|
||||
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
|
||||
await page.getByLabel('Next Recipient Name').fill(newName);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
|
||||
const createdEnvelopeRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelope: {
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
});
|
||||
|
||||
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
const template = await seedDirectTemplate({
|
||||
title: 'Team direct template link 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: template.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
const originalName = 'Signer 2';
|
||||
const originalSecondSignerEmail = seedTestEmail();
|
||||
|
||||
// Add another signer
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
signingOrder: 2,
|
||||
envelopeId: template.id,
|
||||
email: originalSecondSignerEmail,
|
||||
name: originalName,
|
||||
token: Math.random().toString().slice(2, 7),
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
});
|
||||
|
||||
// Check that the direct template link is accessible.
|
||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
const currentName = 'John Doe';
|
||||
const currentEmail = seedTestEmail();
|
||||
|
||||
await page.getByPlaceholder('Enter Your Name').fill(currentName);
|
||||
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
|
||||
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
|
||||
expect(nextRecipientNameInputValue).toBe(originalName);
|
||||
|
||||
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
|
||||
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
|
||||
|
||||
const newName = 'Hello';
|
||||
const newSecondSignerEmail = seedTestEmail();
|
||||
|
||||
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
|
||||
await page.getByLabel('Next Recipient Name').fill(newName);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/sign/);
|
||||
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
|
||||
|
||||
const createdEnvelopeRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelope: {
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSecondRecipient = createdEnvelopeRecipients.find(
|
||||
(recipient) => recipient.signingOrder === 2,
|
||||
);
|
||||
|
||||
expect(updatedSecondRecipient?.name).toBe(newName);
|
||||
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
|
||||
});
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
"devDependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/node": "^20"
|
||||
"@napi-rs/canvas": "^0.1.82",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.12"
|
||||
"start-server-and-test": "^2.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/app-tests/visual-regression/alignment-pdf-0.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-1.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-2.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-3.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
packages/app-tests/visual-regression/blank-certificate.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-0.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-1.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-2.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-3.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-4.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-5.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-6.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-7.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
56611
packages/assets/pdf.worker.min.js
vendored
@@ -72,7 +72,11 @@ export class AuthClient {
|
||||
public async getSession() {
|
||||
const response = await this.client['session-json'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -82,13 +86,19 @@ export class AuthClient {
|
||||
public async getSessions() {
|
||||
const response = await this.client['sessions'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<{ sessions: ActiveSession[] }>(result);
|
||||
}
|
||||
|
||||
// !: Unused for now since it isn't providing the type narrowing
|
||||
// !: we need.
|
||||
private async handleError<T>(response: ClientResponse<T>): Promise<void> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -101,7 +111,11 @@ export class AuthClient {
|
||||
getMany: async () => {
|
||||
const response = await this.client['accounts'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -112,7 +126,11 @@ export class AuthClient {
|
||||
param: { accountId },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,41 +149,75 @@ export class AuthClient {
|
||||
},
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
|
||||
updatePassword: async (data: TUpdatePasswordSchema) => {
|
||||
const response = await this.client['email-password']['update-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
forgotPassword: async (data: TForgotPasswordSchema) => {
|
||||
const response = await this.client['email-password']['forgot-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resetPassword: async (data: TResetPasswordSchema) => {
|
||||
const response = await this.client['email-password']['reset-password'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
signUp: async (data: TSignUpSchema) => {
|
||||
const response = await this.client['email-password']['signup'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
resendVerifyEmail: async (data: TResendVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['resend-verify-email'].$post({
|
||||
json: data,
|
||||
});
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
|
||||
verifyEmail: async (data: TVerifyEmailSchema) => {
|
||||
const response = await this.client['email-password']['verify-email'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
@@ -174,23 +226,43 @@ export class AuthClient {
|
||||
public twoFactor = {
|
||||
setup: async () => {
|
||||
const response = await this.client['two-factor'].setup.$post();
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enable: async (data: TEnableTwoFactorRequestSchema) => {
|
||||
const response = await this.client['two-factor'].enable.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
disable: async (data: TDisableTwoFactorRequestSchema) => {
|
||||
const response = await this.client['two-factor'].disable.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
},
|
||||
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
|
||||
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
@@ -199,7 +271,12 @@ export class AuthClient {
|
||||
public passkey = {
|
||||
signIn: async (data: TPasskeySignin) => {
|
||||
const response = await this.client['passkey'].authorize.$post({ json: data });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
handleSignInRedirect(data.redirectPath);
|
||||
},
|
||||
@@ -211,7 +288,11 @@ export class AuthClient {
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -228,7 +309,11 @@ export class AuthClient {
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -241,7 +326,12 @@ export class AuthClient {
|
||||
public oidc = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
||||
await this.handleError(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -256,7 +346,11 @@ export class AuthClient {
|
||||
param: { orgUrl },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@hono/standard-validator": "^0.1.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"arctic": "^3.1.0",
|
||||
"hono": "4.7.0",
|
||||
"luxon": "^3.5.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "4.10.6",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,13 @@ type HandleOAuthAuthorizeUrlOptions = {
|
||||
/**
|
||||
* Optional prompt to pass to the authorization endpoint.
|
||||
*/
|
||||
prompt?: 'login' | 'consent' | 'select_account';
|
||||
prompt?: 'none' | 'login' | 'consent' | 'select_account';
|
||||
};
|
||||
|
||||
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
|
||||
|
||||
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
|
||||
const { c, clientOptions, redirectPath, prompt = 'login' } = options;
|
||||
const { c, clientOptions, redirectPath } = options;
|
||||
|
||||
if (!clientOptions.clientId || !clientOptions.clientSecret) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
@@ -63,7 +63,11 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
|
||||
);
|
||||
|
||||
// Pass the prompt to the authorization endpoint.
|
||||
url.searchParams.append('prompt', prompt);
|
||||
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
|
||||
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
|
||||
|
||||
url.searchParams.append('prompt', prompt);
|
||||
}
|
||||
|
||||
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
|
||||
...sessionCookieOptions,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -13,36 +13,36 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"clean": "rimraf node_modules",
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/body": "0.0.4",
|
||||
"@react-email/button": "0.0.11",
|
||||
"@react-email/column": "0.0.8",
|
||||
"@react-email/container": "0.0.10",
|
||||
"@react-email/font": "0.0.4",
|
||||
"@react-email/head": "0.0.6",
|
||||
"@react-email/heading": "0.0.9",
|
||||
"@react-email/hr": "0.0.6",
|
||||
"@react-email/html": "0.0.6",
|
||||
"@react-email/img": "0.0.6",
|
||||
"@react-email/link": "0.0.6",
|
||||
"@react-email/preview": "0.0.7",
|
||||
"@react-email/render": "0.0.9",
|
||||
"@react-email/row": "0.0.6",
|
||||
"@react-email/section": "0.0.10",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"@react-email/text": "0.0.6",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react-email": "1.9.5",
|
||||
"resend": "2.0.0"
|
||||
"@documenso/nodemailer-resend": "4.0.0",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"react-email": "^5.0.4",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"tsup": "^7.1.0"
|
||||
"@types/nodemailer": "^6.4.21"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import * as ReactEmail from '@react-email/render';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
@@ -7,6 +9,7 @@ import { BrandingProvider, type BrandingSettings } from './providers/branding';
|
||||
|
||||
export type RenderOptions = ReactEmail.Options & {
|
||||
branding?: BrandingSettings;
|
||||
i18n?: I18n;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@@ -16,17 +19,46 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, i18n, ...otherOptions } = options ?? {};
|
||||
|
||||
if (!i18n) {
|
||||
throw new Error('i18n is required');
|
||||
}
|
||||
|
||||
return ReactEmail.render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>
|
||||
</I18nProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
@@ -35,17 +67,19 @@ export const renderAsync = async (element: React.ReactNode, options?: RenderOpti
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Heading, Img, Section, Text } from '../components';
|
||||
|
||||
@@ -46,7 +46,11 @@ export const TemplateAccessAuth2FA = ({
|
||||
</Section>
|
||||
|
||||
<Text className="mt-4 text-center text-sm text-slate-600">
|
||||
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
|
||||
<Plural
|
||||
value={expiresInMinutes}
|
||||
one="This code will expire in # minute."
|
||||
other="This code will expire in # minutes."
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-4 text-center text-sm text-slate-500">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
export type TemplateCustomMessageBodyProps = {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export const TemplateCustomMessageBody = ({ text }: TemplateCustomMessageBodyProps) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.trim()
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\n\s*\n+/g, '\n\n')
|
||||
.replace(/\n{2,}/g, '\n\n');
|
||||
|
||||
const paragraphs = normalized.split('\n\n');
|
||||
|
||||
return paragraphs.map((paragraph, i) => (
|
||||
<p
|
||||
key={`p-${i}`}
|
||||
className="whitespace-pre-line break-words font-sans text-base text-slate-400"
|
||||
>
|
||||
{paragraph.split('\n').map((line, j) => (
|
||||
<React.Fragment key={`line-${i}-${j}`}>
|
||||
{j > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
export default TemplateCustomMessageBody;
|
||||
@@ -8,6 +8,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body';
|
||||
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
|
||||
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@@ -105,7 +106,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
{customBody ? (
|
||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||
<TemplateCustomMessageBody text={customBody} />
|
||||
) : (
|
||||
<Trans>
|
||||
{inviterName} has invited you to {action} the document "{documentName}".
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"eslint-config-turbo": "^1.12.5",
|
||||
"eslint-plugin-package-json": "^0.31.0",
|
||||
"eslint-config-next": "^15",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-package-json": "^0.85.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const validateNumberField = (
|
||||
|
||||
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
|
||||
|
||||
if (numberFormat) {
|
||||
if (numberFormat && value.length > 0) {
|
||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||
|
||||
if (!foundRegex) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -63,6 +63,8 @@ type UseEditorFieldsResponse = {
|
||||
// Selected recipient
|
||||
selectedRecipient: Recipient | null;
|
||||
setSelectedRecipient: (recipientId: number | null) => void;
|
||||
|
||||
resetForm: (fields?: Field[]) => void;
|
||||
};
|
||||
|
||||
export const useEditorFields = ({
|
||||
@@ -72,24 +74,30 @@ export const useEditorFields = ({
|
||||
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
|
||||
const generateDefaultValues = (fields?: Field[]) => {
|
||||
const formFields = (fields || envelope.fields).map(
|
||||
(field): TLocalField => ({
|
||||
id: field.id,
|
||||
formId: nanoid(),
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
fields: formFields,
|
||||
};
|
||||
};
|
||||
|
||||
const form = useForm<TEditorFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: envelope.fields.map(
|
||||
(field): TLocalField => ({
|
||||
id: field.id,
|
||||
formId: nanoid(),
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
}),
|
||||
),
|
||||
},
|
||||
defaultValues: generateDefaultValues(),
|
||||
resolver: zodResolver(ZEditorFieldsFormSchema),
|
||||
});
|
||||
|
||||
@@ -162,11 +170,13 @@ export const useEditorFields = ({
|
||||
);
|
||||
|
||||
const setFieldId = (formId: string, id: number) => {
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
const { fields } = form.getValues();
|
||||
|
||||
const index = fields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, {
|
||||
...localFields[index],
|
||||
...fields[index],
|
||||
id,
|
||||
});
|
||||
}
|
||||
@@ -275,6 +285,10 @@ export const useEditorFields = ({
|
||||
setSelectedRecipientId(foundRecipient?.id ?? null);
|
||||
};
|
||||
|
||||
const resetForm = (fields?: Field[]) => {
|
||||
form.reset(generateDefaultValues(fields));
|
||||
};
|
||||
|
||||
return {
|
||||
// Core state
|
||||
localFields,
|
||||
@@ -298,6 +312,8 @@ export const useEditorFields = ({
|
||||
// Selected recipient
|
||||
selectedRecipient,
|
||||
setSelectedRecipient,
|
||||
|
||||
resetForm,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -73,6 +75,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
canvas,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport: renderViewport,
|
||||
@@ -105,6 +108,10 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
void document.fonts.ready.then(function () {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -122,5 +129,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,34 +19,61 @@ 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[];
|
||||
envelopeStatus: TEnvelope['status'];
|
||||
envelopeType: TEnvelope['type'];
|
||||
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'>;
|
||||
|
||||
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
|
||||
|
||||
/**
|
||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||
*
|
||||
* 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 +95,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 [currentItem, setCurrentItem] = 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 +148,7 @@ export const EnvelopeRenderProvider = ({
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
[envelopeItem.id]: {
|
||||
status: 'error',
|
||||
},
|
||||
}));
|
||||
@@ -116,8 +156,8 @@ export const EnvelopeRenderProvider = ({
|
||||
};
|
||||
|
||||
const getPdfBuffer = useCallback(
|
||||
(documentDataId: string) => {
|
||||
return files[documentDataId] || null;
|
||||
(envelopeItemId: string) => {
|
||||
return files[envelopeItemId] || null;
|
||||
},
|
||||
[files],
|
||||
);
|
||||
@@ -125,11 +165,15 @@ export const EnvelopeRenderProvider = ({
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
setItem(foundItem ?? null);
|
||||
setCurrentItem(foundItem ?? null);
|
||||
};
|
||||
|
||||
// Set the selected item to the first item if none is set.
|
||||
useEffect(() => {
|
||||
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
|
||||
setCurrentItem(null);
|
||||
}
|
||||
|
||||
if (!currentItem && envelopeItems.length > 0) {
|
||||
setCurrentEnvelopeItem(envelopeItems[0].id);
|
||||
}
|
||||
@@ -137,13 +181,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);
|
||||
@@ -160,10 +209,16 @@ export const EnvelopeRenderProvider = ({
|
||||
value={{
|
||||
getPdfBuffer,
|
||||
envelopeItems,
|
||||
envelopeStatus: envelope.status,
|
||||
envelopeType: envelope.type,
|
||||
currentEnvelopeItem: currentItem,
|
||||
setCurrentEnvelopeItem,
|
||||
fields: fields ?? [],
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
renderError,
|
||||
setRenderError,
|
||||
overrideSettings,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -6,13 +6,15 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () =>
|
||||
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||
|
||||
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
|
||||
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
|
||||
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_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';
|
||||
|
||||
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';
|
||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||
|
||||
@@ -51,21 +51,21 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
||||
[DocumentSignatureType.DRAW]: {
|
||||
label: msg({
|
||||
message: `Draw`,
|
||||
context: `Draw signatute type`,
|
||||
context: `Draw signature`,
|
||||
}),
|
||||
value: DocumentSignatureType.DRAW,
|
||||
},
|
||||
[DocumentSignatureType.TYPE]: {
|
||||
label: msg({
|
||||
message: `Type`,
|
||||
context: `Type signatute type`,
|
||||
context: `Type signature`,
|
||||
}),
|
||||
value: DocumentSignatureType.TYPE,
|
||||
},
|
||||
[DocumentSignatureType.UPLOAD]: {
|
||||
label: msg({
|
||||
message: `Upload`,
|
||||
context: `Upload signatute type`,
|
||||
context: `Upload signature`,
|
||||
}),
|
||||
value: DocumentSignatureType.UPLOAD,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
|
||||
export const SUPPORTED_LANGUAGE_CODES = [
|
||||
'de',
|
||||
'en',
|
||||
'fr',
|
||||
'es',
|
||||
'it',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ja',
|
||||
'ko',
|
||||
'zh',
|
||||
] as const;
|
||||
|
||||
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
|
||||
|
||||
@@ -54,6 +65,22 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
|
||||
short: 'pl',
|
||||
full: 'Polish',
|
||||
},
|
||||
'pt-BR': {
|
||||
short: 'pt-BR',
|
||||
full: 'Portuguese (Brazil)',
|
||||
},
|
||||
ja: {
|
||||
short: 'ja',
|
||||
full: 'Japanese',
|
||||
},
|
||||
ko: {
|
||||
short: 'ko',
|
||||
full: 'Korean',
|
||||
},
|
||||
zh: {
|
||||
short: 'zh',
|
||||
full: 'Chinese',
|
||||
},
|
||||
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
|
||||
|
||||
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>
|
||||
|
||||
@@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
optimizeParallelism?: boolean;
|
||||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Context as HonoContext } from 'hono';
|
||||
import type { Context, Handler, InngestFunction } from 'inngest';
|
||||
import { Inngest as InngestClient } from 'inngest';
|
||||
import type { Logger } from 'inngest';
|
||||
import { serve as createHonoPagesRoute } from 'inngest/hono';
|
||||
import type { Logger } from 'inngest/middleware/logger';
|
||||
|
||||
import { env } from '../../utils/env';
|
||||
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
|
||||
@@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
|
||||
{
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
optimizeParallelism: job.optimizeParallelism ?? false,
|
||||
},
|
||||
{
|
||||
event: job.trigger.name,
|
||||
|
||||
@@ -213,7 +213,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
}) {
|
||||
const { jobId, jobDefinitionId, data, isRetry } = options;
|
||||
|
||||
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/jobs/${jobDefinitionId}/${jobId}`;
|
||||
const endpoint = `${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/jobs/${jobDefinitionId}/${jobId}`;
|
||||
const signature = sign(data);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
@@ -49,7 +49,8 @@ export const run = async ({
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
|
||||
@@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
||||
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
||||
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
||||
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
@@ -62,171 +61,141 @@ export const run = async ({
|
||||
}) => {
|
||||
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
field: {
|
||||
include: {
|
||||
signature: true,
|
||||
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
field: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new Error('At least one envelope item required');
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
|
||||
if (!isComplete) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Document is not complete',
|
||||
});
|
||||
}
|
||||
|
||||
// Seems silly but we need to do this in case the job is re-ran
|
||||
// after it has already run through the update task further below.
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const documentStatus = await io.runTask('get-document-status', async () => {
|
||||
return envelope.status;
|
||||
});
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new Error('At least one envelope item required');
|
||||
}
|
||||
|
||||
// This is the same case as above.
|
||||
let envelopeItems = await io.runTask(
|
||||
'get-document-data-id',
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async () => {
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
return envelope.envelopeItems.map(({ field, ...rest }) => ({
|
||||
...rest,
|
||||
}));
|
||||
},
|
||||
);
|
||||
const settings = await getTeamSettings({
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
if (envelopeItems.length < 1) {
|
||||
throw new Error(`Document ${envelope.id} has no envelope items`);
|
||||
}
|
||||
const isComplete =
|
||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!isComplete) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Document is not complete',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||
const rejectedRecipient = recipients.find(
|
||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
let envelopeItems = envelope.envelopeItems;
|
||||
|
||||
const isRejected = Boolean(rejectedRecipient);
|
||||
if (envelopeItems.length < 1) {
|
||||
throw new Error(`Document ${envelope.id} has no envelope items`);
|
||||
}
|
||||
|
||||
// Get the rejection reason from the rejected recipient
|
||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Skip the field check if the document is rejected
|
||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
||||
}
|
||||
|
||||
if (isResealing) {
|
||||
// If we're resealing we want to use the initial data for the document
|
||||
// so we aren't placing fields on top of eachother.
|
||||
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
||||
...envelopeItem,
|
||||
documentData: {
|
||||
...envelopeItem.documentData,
|
||||
data: envelopeItem.documentData.initialData,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (!envelope.qrToken) {
|
||||
await prisma.envelope.update({
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
envelopeId: envelope.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||
const rejectedRecipient = recipients.find(
|
||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
|
||||
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
|
||||
legacyDocumentId,
|
||||
documentMeta: envelope.documentMeta,
|
||||
settings,
|
||||
});
|
||||
const isRejected = Boolean(rejectedRecipient);
|
||||
|
||||
const newDocumentData = await Promise.all(
|
||||
envelopeItems.map(async (envelopeItem) =>
|
||||
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
|
||||
const envelopeItemFields = envelope.envelopeItems.find(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
// Get the rejection reason from the rejected recipient
|
||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||
|
||||
if (!envelopeItemFields) {
|
||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||
}
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
|
||||
return decorateAndSignPdf({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
certificateData,
|
||||
auditLogData,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Skip the field check if the document is rejected
|
||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
||||
}
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
if (isResealing) {
|
||||
// If we're resealing we want to use the initial data for the document
|
||||
// so we aren't placing fields on top of eachother.
|
||||
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
||||
...envelopeItem,
|
||||
documentData: {
|
||||
...envelopeItem.documentData,
|
||||
data: envelopeItem.documentData.initialData,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (postHog) {
|
||||
postHog.capture({
|
||||
distinctId: nanoid(),
|
||||
event: 'App: Document Sealed',
|
||||
properties: {
|
||||
documentId: envelope.id,
|
||||
if (!envelope.qrToken) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
|
||||
legacyDocumentId,
|
||||
documentMeta: envelope.documentMeta,
|
||||
settings,
|
||||
});
|
||||
|
||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||
|
||||
for (const envelopeItem of envelopeItems) {
|
||||
const envelopeItemFields = envelope.envelopeItems.find(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
|
||||
if (!envelopeItemFields) {
|
||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||
}
|
||||
|
||||
const result = await decorateAndSignPdf({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
},
|
||||
});
|
||||
}
|
||||
rejectionReason,
|
||||
certificateData,
|
||||
auditLogData,
|
||||
});
|
||||
|
||||
newDocumentData.push(result);
|
||||
}
|
||||
|
||||
await io.runTask('update-document', async () => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
||||
const newData = await tx.documentData.findFirstOrThrow({
|
||||
@@ -268,18 +237,24 @@ export const run = async ({
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
envelopeId: envelope.id,
|
||||
envelopeStatus: envelope.status,
|
||||
isRejected,
|
||||
};
|
||||
});
|
||||
|
||||
await io.runTask('send-completed-email', async () => {
|
||||
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
||||
|
||||
if (isResealing && !isDocumentCompleted(envelope.status)) {
|
||||
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
|
||||
shouldSendCompletedEmail = sendEmail;
|
||||
}
|
||||
|
||||
if (shouldSendCompletedEmail) {
|
||||
await sendCompletedEmail({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
@@ -287,7 +262,7 @@ export const run = async ({
|
||||
|
||||
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||
name: 'Seal Document',
|
||||
version: '1.0.0',
|
||||
optimizeParallelism: true,
|
||||
trigger: {
|
||||
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
|
||||
|
||||
@@ -15,51 +15,52 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/client-sesv2": "^3.410.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@aws-sdk/client-s3": "^3.936.0",
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.936.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.936.0",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/macro": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@noble/ciphers": "0.6.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"jose": "^6.0.0",
|
||||
"kysely": "0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.45.1",
|
||||
"jose": "^6.1.2",
|
||||
"konva": "^10.0.9",
|
||||
"kysely": "0.28.8",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "1.52.0",
|
||||
"posthog-js": "^1.245.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "^4.18.0",
|
||||
"react": "^18",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"react-pdf": "^10.2.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
"stripe": "^12.18.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.52.0",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
"@playwright/browser-chromium": "1.56.1",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,14 @@ export const adminFindDocuments = async ({
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
title: true,
|
||||
order: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export type OrganisationSummary = {
|
||||
totalTeams: number;
|
||||
totalMembers: number;
|
||||
totalDocuments: number;
|
||||
activeDocuments: number;
|
||||
completedDocuments: number;
|
||||
volumeThisPeriod: number;
|
||||
volumeAllTime: number;
|
||||
};
|
||||
|
||||
export type OrganisationDetailedInsights = {
|
||||
teams: TeamInsights[];
|
||||
users: UserInsights[];
|
||||
documents: DocumentInsights[];
|
||||
totalPages: number;
|
||||
summary?: OrganisationSummary;
|
||||
};
|
||||
|
||||
export type TeamInsights = {
|
||||
id: number;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
documentCount: number;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type UserInsights = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
documentCount: number;
|
||||
signedDocumentCount: number;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type DocumentInsights = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: DocumentStatus;
|
||||
teamName: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
};
|
||||
|
||||
export type GetOrganisationDetailedInsightsOptions = {
|
||||
organisationId: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
dateRange?: DateRange;
|
||||
view: 'teams' | 'users' | 'documents';
|
||||
};
|
||||
|
||||
export async function getOrganisationDetailedInsights({
|
||||
organisationId,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
dateRange = 'last30days',
|
||||
view,
|
||||
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const now = new Date();
|
||||
let createdAtFrom: Date | null = null;
|
||||
|
||||
switch (dateRange) {
|
||||
case 'last30days': {
|
||||
createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
case 'last90days': {
|
||||
createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
case 'lastYear': {
|
||||
createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
}
|
||||
case 'allTime':
|
||||
default:
|
||||
createdAtFrom = null;
|
||||
break;
|
||||
}
|
||||
|
||||
const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
|
||||
|
||||
const viewData = await (async () => {
|
||||
switch (view) {
|
||||
case 'teams':
|
||||
return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
|
||||
case 'users':
|
||||
return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
|
||||
case 'documents':
|
||||
return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
|
||||
default:
|
||||
throw new Error(`Invalid view: ${view}`);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...viewData,
|
||||
summary: summaryData,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTeamInsights(
|
||||
organisationId: string,
|
||||
offset: number,
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const teamsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team as t')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
|
||||
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.select([
|
||||
't.id as id',
|
||||
't.name as name',
|
||||
't.createdAt as createdAt',
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT e.id)`
|
||||
).as('documentCount'),
|
||||
])
|
||||
.groupBy(['t.id', 't.name', 't.createdAt'])
|
||||
.orderBy('documentCount', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team as t')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
|
||||
const count = Number(countResult[0]?.count || 0);
|
||||
|
||||
return {
|
||||
teams: teams as TeamInsights[],
|
||||
users: [],
|
||||
documents: [],
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserInsights(
|
||||
organisationId: string,
|
||||
offset: number,
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const usersBase = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||
.where('om.organisationId', '=', organisationId)
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('e.userId', '=', 'u.id')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as td', (join) =>
|
||||
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
|
||||
)
|
||||
.leftJoin('Recipient as r', (join) =>
|
||||
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
|
||||
)
|
||||
.leftJoin('Envelope as se', (join) =>
|
||||
join
|
||||
.onRef('se.id', '=', 'r.envelopeId')
|
||||
.on('se.deletedAt', 'is', null)
|
||||
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as ts', (join) =>
|
||||
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
|
||||
);
|
||||
|
||||
const usersQuery = usersBase
|
||||
.select([
|
||||
'u.id as id',
|
||||
'u.name as name',
|
||||
'u.email as email',
|
||||
'u.createdAt as createdAt',
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||
).as('documentCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
|
||||
).as('signedDocumentCount'),
|
||||
])
|
||||
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
|
||||
.orderBy('u.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||
.where('om.organisationId', '=', organisationId)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
|
||||
const count = Number(countResult[0]?.count || 0);
|
||||
|
||||
return {
|
||||
teams: [],
|
||||
users: users as UserInsights[],
|
||||
documents: [],
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
async function getDocumentInsights(
|
||||
organisationId: string,
|
||||
offset: number,
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
let documentsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 'e.teamId', 't.id')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
|
||||
|
||||
if (createdAtFrom) {
|
||||
documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
|
||||
}
|
||||
|
||||
documentsQuery = documentsQuery
|
||||
.select([
|
||||
'e.id as id',
|
||||
'e.title as title',
|
||||
'e.status as status',
|
||||
'e.createdAt as createdAt',
|
||||
'e.completedAt as completedAt',
|
||||
't.name as teamName',
|
||||
])
|
||||
.orderBy('e.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
|
||||
let countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 'e.teamId', 't.id')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
|
||||
|
||||
if (createdAtFrom) {
|
||||
countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
|
||||
}
|
||||
|
||||
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [documents, countResult] = await Promise.all([
|
||||
documentsQuery.execute(),
|
||||
countQuery.execute(),
|
||||
]);
|
||||
|
||||
const count = Number((countResult[0] as { count: number })?.count || 0);
|
||||
|
||||
return {
|
||||
teams: [],
|
||||
users: [],
|
||||
documents: documents.map((doc) => ({
|
||||
...doc,
|
||||
id: String((doc as { id: number }).id),
|
||||
})) as DocumentInsights[],
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrganisationSummary(
|
||||
organisationId: string,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationSummary> {
|
||||
const summaryQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where('o.id', '=', organisationId)
|
||||
.select([
|
||||
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||
'totalTeams',
|
||||
),
|
||||
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||
'totalMembers',
|
||||
),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
|
||||
)`.as('totalDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
|
||||
)`.as('activeDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('completedDocuments'),
|
||||
(createdAtFrom
|
||||
? sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
AND e2."createdAt" >= ${createdAtFrom}
|
||||
)`
|
||||
: sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
)`
|
||||
).as('volumeThisPeriod'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('volumeAllTime'),
|
||||
]);
|
||||
|
||||
const result = await summaryQuery.executeTakeFirst();
|
||||
|
||||
return {
|
||||
totalTeams: Number(result?.totalTeams || 0),
|
||||
totalMembers: Number(result?.totalMembers || 0),
|
||||
totalDocuments: Number(result?.totalDocuments || 0),
|
||||
activeDocuments: Number(result?.activeDocuments || 0),
|
||||
completedDocuments: Number(result?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(result?.volumeAllTime || 0),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export type SigningVolume = {
|
||||
export type OrganisationInsights = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
customerId: string | null;
|
||||
subscriptionStatus?: string;
|
||||
teamCount?: number;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export type GetSigningVolumeOptions = {
|
||||
@@ -28,28 +32,26 @@ export async function getSigningVolume({
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null),
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.where('e.type', '=', EnvelopeType.DOCUMENT)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'o.name']);
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@@ -68,19 +70,127 @@ export async function getSigningVolume({
|
||||
findQuery = findQuery.limit(perPage).offset(offset);
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
return {
|
||||
leaderboard: results,
|
||||
organisations: results,
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
|
||||
dateRange?: DateRange;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
};
|
||||
|
||||
export async function getOrganisationInsights({
|
||||
search = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
dateRange = 'last30days',
|
||||
startDate,
|
||||
endDate,
|
||||
}: GetOrganisationInsightsOptions) {
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const now = new Date();
|
||||
let dateCondition = sql`1=1`;
|
||||
|
||||
if (startDate && endDate) {
|
||||
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
} else {
|
||||
switch (dateRange) {
|
||||
case 'last30days': {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'last90days': {
|
||||
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'lastYear': {
|
||||
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'allTime':
|
||||
default:
|
||||
dateCondition = sql`1=1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
|
||||
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select([
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||
'signingVolume',
|
||||
),
|
||||
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
|
||||
'subscriptionStatus',
|
||||
),
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
findQuery = findQuery.orderBy('name', sortOrder);
|
||||
break;
|
||||
case 'createdAt':
|
||||
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
||||
break;
|
||||
case 'signingVolume':
|
||||
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
||||
break;
|
||||
default:
|
||||
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
||||
}
|
||||
|
||||
findQuery = findQuery.limit(perPage).offset(offset);
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
return {
|
||||
organisations: results,
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const getMonthlyActiveUsers = async () => {
|
||||
)
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.where(() => sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
|
||||
import type { TRecipientAccessAuth } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
@@ -37,7 +37,6 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
id: EnvelopeIdOptions;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
requestMetadata?: RequestMetadata;
|
||||
nextSigner?: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
@@ -99,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
url: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
brandingEnabled: true,
|
||||
|
||||
@@ -63,5 +63,8 @@ export const getDocumentWithDetailsById = async ({
|
||||
documentId: legacyDocumentId,
|
||||
password: null,
|
||||
},
|
||||
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
|
||||
...envelopeItem,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
folderId,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
@@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
...visibilityFiltersWhereInput,
|
||||
...searchFilter,
|
||||
AND: [
|
||||
...(Array.isArray(visibilityFiltersWhereInput.AND)
|
||||
? visibilityFiltersWhereInput.AND
|
||||
: visibilityFiltersWhereInput.AND
|
||||
? [visibilityFiltersWhereInput.AND]
|
||||
: []),
|
||||
searchFilter,
|
||||
rootPageFilter,
|
||||
folderId ? { folderId } : {},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
@@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
@@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
@@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const resendDocument = async ({
|
||||
recipients,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: ResendDocumentOptions): Promise<void> => {
|
||||
}: ResendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -103,7 +103,7 @@ export const resendDocument = async ({
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
return;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
|
||||
@@ -230,4 +230,6 @@ export const resendDocument = async ({
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
@@ -20,7 +20,14 @@ 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,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@@ -174,72 +181,22 @@ 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) {
|
||||
if (field.type === FieldType.RADIO) {
|
||||
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
for (const unknownField of envelope.fields) {
|
||||
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
|
||||
|
||||
const checkedItemIndex = values.findIndex((value) => value.checked);
|
||||
|
||||
if (checkedItemIndex !== -1) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toRadioCustomText(checkedItemIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
const {
|
||||
values = [],
|
||||
validationRule,
|
||||
validationLength,
|
||||
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedIndices: number[] = [];
|
||||
|
||||
values.forEach((value, i) => {
|
||||
if (value.checked) {
|
||||
checkedIndices.push(i);
|
||||
}
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
let isValid = true;
|
||||
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||
|
||||
if (!validation) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid checkbox validation rule',
|
||||
});
|
||||
}
|
||||
|
||||
isValid = validateCheckboxLength(
|
||||
checkedIndices.length,
|
||||
validation.value,
|
||||
validationLength,
|
||||
);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toCheckboxCustomText(checkedIndices),
|
||||
});
|
||||
}
|
||||
// Only auto-insert fields if the recipient has not been sent the document yet.
|
||||
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
|
||||
fieldsToAutoInsert.push(fieldToAutoInsert);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,6 +216,7 @@ export const sendDocument = async ({
|
||||
if (envelope.internalVersion === 2) {
|
||||
const autoInsertedFields = await Promise.all(
|
||||
fieldsToAutoInsert.map(async (field) => {
|
||||
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
|
||||
return await tx.field.update({
|
||||
where: {
|
||||
id: field.fieldId,
|
||||
@@ -371,3 +329,113 @@ const injectFormValuesIntoDocument = async (
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the auto insertion values for a given field.
|
||||
*
|
||||
* If field is not auto insertable, returns `null`.
|
||||
*/
|
||||
export const extractFieldAutoInsertValues = (
|
||||
unknownField: Field,
|
||||
): { fieldId: number; customText: string } | null => {
|
||||
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;
|
||||
|
||||
// Auto insert text fields with prefilled values.
|
||||
if (field.type === FieldType.TEXT) {
|
||||
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (text) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto insert number fields with prefilled values.
|
||||
if (field.type === FieldType.NUMBER) {
|
||||
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (value) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto insert radio fields with the pre-checked value.
|
||||
if (field.type === FieldType.RADIO) {
|
||||
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedItemIndex = values.findIndex((value) => value.checked);
|
||||
|
||||
if (checkedItemIndex !== -1) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: toRadioCustomText(checkedItemIndex),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto insert dropdown fields with the default value.
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: defaultValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto insert checkbox fields with the pre-checked values.
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
const {
|
||||
values = [],
|
||||
validationRule,
|
||||
validationLength,
|
||||
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedIndices: number[] = [];
|
||||
|
||||
values.forEach((value, i) => {
|
||||
if (value.checked) {
|
||||
checkedIndices.push(i);
|
||||
}
|
||||
});
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||
|
||||
if (!validation) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid checkbox validation rule',
|
||||
});
|
||||
}
|
||||
|
||||
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
|
||||
}
|
||||
|
||||
if (isValid && checkedIndices.length > 0) {
|
||||
return {
|
||||
fieldId,
|
||||
customText: toCheckboxCustomText(checkedIndices),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -31,26 +31,16 @@ export const viewedDocument = async ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: {
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { envelope } = recipient;
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
|
||||
envelopeId: envelope.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -86,7 +76,7 @@ export const viewedDocument = async ({
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
envelopeId: envelope.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -103,6 +93,16 @@ export const viewedDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: recipient.envelopeId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
|
||||
@@ -16,11 +16,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,
|
||||
@@ -34,6 +39,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;
|
||||
@@ -46,7 +70,6 @@ export type CreateEnvelopeOptions = {
|
||||
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
|
||||
formValues?: TDocumentFormValues;
|
||||
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
|
||||
templateType?: TemplateType;
|
||||
@@ -56,7 +79,7 @@ export type CreateEnvelopeOptions = {
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
@@ -83,7 +106,6 @@ export const createEnvelope = async ({
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
templateType,
|
||||
@@ -142,6 +164,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) => {
|
||||
@@ -219,7 +242,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, {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
@@ -98,14 +100,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
// Currently not using this since for direct templates "User" access means they just need to be
|
||||
// logged in.
|
||||
// const documentAccessValid = await isRecipientAuthorized({
|
||||
// type: 'ACCESS',
|
||||
// documentAuthOptions: envelope.authOptions,
|
||||
// recipient,
|
||||
// userId,
|
||||
// authOptions: accessAuth,
|
||||
// });
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
});
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
|
||||
match(auth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
@@ -128,7 +144,20 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
envelope,
|
||||
recipient: {
|
||||
...recipient,
|
||||
token: envelope.directLink?.token || '',
|
||||
directToken: envelope.directLink?.token || '',
|
||||
fields: recipient.fields.map((field) => {
|
||||
const autoInsertValue = extractFieldAutoInsertValues(field);
|
||||
|
||||
if (!autoInsertValue) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
inserted: true,
|
||||
customText: autoInsertValue.customText,
|
||||
};
|
||||
}),
|
||||
},
|
||||
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';
|
||||
@@ -12,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { ZFieldSchema } from '../../types/field';
|
||||
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
|
||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
@@ -64,28 +63,21 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
rejectionReason: true,
|
||||
})
|
||||
.extend({
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
fields: ZEnvelopeFieldSchema.extend({
|
||||
signature: SignatureSchema.pick({
|
||||
signatureImageAsBase64: true,
|
||||
typedSignature: true,
|
||||
}).nullish(),
|
||||
}).array(),
|
||||
})
|
||||
.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 +109,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
directToken: z.string().nullish(),
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
@@ -199,11 +192,7 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
directLink: {
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
directLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientUserAccount = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: recipient.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
recipientEmail: recipient.email,
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
String(numberFieldParsedMeta.value || ''),
|
||||
numberFieldParsedMeta,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -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,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
|
||||
{
|
||||
name: 'language',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
|
||||
NEXT_PUBLIC_WEBAPP_URL,
|
||||
USE_INTERNAL_URL_BROWSERLESS,
|
||||
} from '../../constants/app';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { env } from '../../utils/env';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
@@ -48,14 +52,19 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
{
|
||||
name: 'lang',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
url: USE_INTERNAL_URL_BROWSERLESS()
|
||||
? NEXT_PUBLIC_WEBAPP_URL()
|
||||
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.goto(
|
||||
`${USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`,
|
||||
{
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// !: This is a workaround to ensure the page is loaded correctly.
|
||||
// !: It's not clear why but suddenly browserless cdp connections would
|
||||
|
||||
@@ -88,11 +88,13 @@ export const addUserToOrganisation = async ({
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole,
|
||||
bypassEmail = false,
|
||||
}: {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
organisationGroups: OrganisationGroup[];
|
||||
organisationMemberRole: OrganisationMemberRole;
|
||||
bypassEmail?: boolean;
|
||||
}) => {
|
||||
const organisationGroupToUse = organisationGroups.find(
|
||||
(group) =>
|
||||
@@ -122,13 +124,15 @@ export const addUserToOrganisation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
if (!bypassEmail) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Konva from 'konva';
|
||||
// sort-imports-ignore
|
||||
import 'konva/skia-backend';
|
||||
|
||||
import Konva from 'konva';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
@@ -21,21 +23,19 @@ export const insertFieldInPDFV2 = async ({
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
FontLibrary.use([
|
||||
path.join(fontPath, 'caveat.ttf'),
|
||||
path.join(fontPath, 'noto-sans.ttf'),
|
||||
path.join(fontPath, 'noto-sans-japanese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-chinese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-korean.ttf'),
|
||||
]);
|
||||
FontLibrary.use({
|
||||
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
|
||||
['Noto Sans']: [path.join(fontPath, 'noto-sans.ttf')],
|
||||
['Noto Sans Japanese']: [path.join(fontPath, 'noto-sans-japanese.ttf')],
|
||||
['Noto Sans Chinese']: [path.join(fontPath, 'noto-sans-chinese.ttf')],
|
||||
['Noto Sans Korean']: [path.join(fontPath, 'noto-sans-korean.ttf')],
|
||||
});
|
||||
|
||||
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
const layer = new Konva.Layer();
|
||||
|
||||
const insertedFields = fields.filter((field) => field.inserted);
|
||||
|
||||
// Render the fields onto the layer.
|
||||
for (const field of insertedFields) {
|
||||
for (const field of fields) {
|
||||
renderField({
|
||||
scale: 1,
|
||||
field: {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
|
||||
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)),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { createElement } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Field, Signature } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
@@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||
@@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
nextSigner?: {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CreatedDirectRecipientField = {
|
||||
@@ -77,6 +82,7 @@ type CreatedDirectRecipientField = {
|
||||
|
||||
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
envelopeId: z.string(),
|
||||
documentId: z.number(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
@@ -92,6 +98,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
directTemplateExternalId,
|
||||
signedFieldValues,
|
||||
templateUpdatedAt,
|
||||
nextSigner,
|
||||
requestMetadata,
|
||||
user,
|
||||
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
|
||||
@@ -128,6 +135,17 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
|
||||
}
|
||||
|
||||
if (
|
||||
nextSigner &&
|
||||
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
|
||||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
|
||||
});
|
||||
}
|
||||
|
||||
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
|
||||
directTemplateEnvelope.secondaryId,
|
||||
);
|
||||
@@ -197,6 +215,12 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
|
||||
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
|
||||
|
||||
// Custom logic for V2 to include all fields, since v1 excludes read only
|
||||
// and prefilled fields.
|
||||
if (directTemplateEnvelope.internalVersion === 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Include if it's required or has a signed value
|
||||
return isRequiredField(templateField) || signedFieldValue !== undefined;
|
||||
});
|
||||
@@ -450,19 +474,28 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
signingOrder: directTemplateRecipient.signingOrder,
|
||||
fields: {
|
||||
createMany: {
|
||||
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
|
||||
envelopeId: createdEnvelope.id,
|
||||
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
|
||||
type: templateField.type,
|
||||
page: templateField.page,
|
||||
positionX: templateField.positionX,
|
||||
positionY: templateField.positionY,
|
||||
width: templateField.width,
|
||||
height: templateField.height,
|
||||
customText: customText ?? '',
|
||||
inserted: true,
|
||||
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||
})),
|
||||
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => {
|
||||
let inserted = true;
|
||||
|
||||
// Custom logic for V2 to only insert if values exist.
|
||||
if (directTemplateEnvelope.internalVersion === 2) {
|
||||
inserted = customText !== '';
|
||||
}
|
||||
|
||||
return {
|
||||
envelopeId: createdEnvelope.id,
|
||||
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
|
||||
type: templateField.type,
|
||||
page: templateField.page,
|
||||
positionX: templateField.positionX,
|
||||
positionY: templateField.positionY,
|
||||
width: templateField.width,
|
||||
height: templateField.height,
|
||||
customText: customText ?? '',
|
||||
inserted,
|
||||
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -630,6 +663,77 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
}),
|
||||
];
|
||||
|
||||
if (nextSigner) {
|
||||
const pendingRecipients = await tx.recipient.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
where: {
|
||||
envelopeId: createdEnvelope.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
// Composite sort so our next recipient is always the one with the lowest signing order or id
|
||||
// if there is a tie.
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
const nextRecipient = pendingRecipients[0];
|
||||
|
||||
if (nextRecipient) {
|
||||
auditLogsToCreate.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: createdEnvelope.id,
|
||||
user: {
|
||||
name: user?.name || directRecipientName || '',
|
||||
email: user?.email || directRecipientEmail,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: nextRecipient.email,
|
||||
recipientName: nextRecipient.name,
|
||||
recipientId: nextRecipient.id,
|
||||
recipientRole: nextRecipient.role,
|
||||
changes: [
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||
from: nextRecipient.name,
|
||||
to: nextSigner.name,
|
||||
},
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||
from: nextRecipient.email,
|
||||
to: nextSigner.email,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await tx.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
...(nextSigner && documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: auditLogsToCreate,
|
||||
});
|
||||
@@ -727,6 +831,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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
|
||||
const signature = sign(body);
|
||||
|
||||
await Promise.race([
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
||||