feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@ -14,7 +14,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
@ -84,10 +84,8 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
const retrievedFields = await getFieldsForEnvelope({
envelopeId: document.id,
});
expect(retrievedFields.length).toBe(3);
@ -149,10 +147,8 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
const retrievedFields = await getFieldsForEnvelope({
envelopeId: document.id,
});
expect(retrievedFields.length).toBe(2);
@ -213,10 +209,8 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
const retrievedFields = await getFieldsForEnvelope({
envelopeId: document.id,
});
expect(retrievedFields.length).toBe(4);
@ -260,10 +254,8 @@ test.describe('AutoSave Fields Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
const retrievedFields = await getFieldsForEnvelope({
envelopeId: document.id,
});
expect(retrievedFields.length).toBe(2);
@ -291,3 +283,28 @@ test.describe('AutoSave Fields Step', () => {
}).toPass();
});
});
const getFieldsForEnvelope = async ({ envelopeId }: { envelopeId: string }) => {
const fields = await prisma.field.findMany({
where: {
envelope: {
id: envelopeId,
},
},
include: {
signature: true,
recipient: {
select: {
name: true,
email: true,
signingStatus: true,
},
},
},
orderBy: {
id: 'asc',
},
});
return fields;
};

View File

@ -1,7 +1,8 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@ -17,7 +18,7 @@ const setupDocument = async (page: Page) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
return { user, team, document };
@ -41,8 +42,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -63,8 +68,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -85,8 +94,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -107,8 +120,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -129,8 +146,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -152,8 +173,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -173,8 +198,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -195,8 +224,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -227,8 +260,12 @@ test.describe('AutoSave Settings Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
const retrieved = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});

View File

@ -1,8 +1,10 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@ -17,7 +19,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
@ -47,7 +49,7 @@ test.describe('AutoSave Signers Step', () => {
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
userId: user.id,
teamId: team.id,
});
@ -71,7 +73,7 @@ test.describe('AutoSave Signers Step', () => {
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
userId: user.id,
teamId: team.id,
});
@ -99,7 +101,7 @@ test.describe('AutoSave Signers Step', () => {
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
userId: user.id,
teamId: team.id,
});
@ -145,14 +147,18 @@ test.describe('AutoSave Signers Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
const retrievedDocumentData = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
userId: user.id,
teamId: team.id,
});

View File

@ -1,7 +1,8 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@ -16,7 +17,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
@ -59,8 +60,12 @@ test.describe('AutoSave Subject Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
const retrievedDocumentData = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -81,8 +86,12 @@ test.describe('AutoSave Subject Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
const retrievedDocumentData = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -105,8 +114,12 @@ test.describe('AutoSave Subject Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
const retrievedDocumentData = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});
@ -156,8 +169,12 @@ test.describe('AutoSave Subject Step', () => {
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
const retrievedDocumentData = await getEnvelopeById({
id: {
type: 'envelopeId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
});

View File

@ -50,7 +50,7 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});

View File

@ -69,7 +69,7 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
await page.getByRole('button', { name: 'Send' }).click();
// Wait for send confirmation
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
};
@ -157,7 +157,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
@ -188,7 +188,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
envelopeId: document.id,
},
});
@ -286,7 +286,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
@ -348,7 +348,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});

View File

@ -9,7 +9,6 @@ import {
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
@ -23,7 +22,7 @@ import { signSignaturePad } from '../fixtures/signature';
// Can't use the function in server-only/document due to it indirectly using
// require imports.
const getDocumentByToken = async (token: string) => {
return await prisma.document.findFirstOrThrow({
return await prisma.envelope.findFirstOrThrow({
where: {
recipients: {
some: {
@ -59,7 +58,7 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page.
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
});
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
@ -115,7 +114,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
@ -200,7 +199,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
@ -298,7 +297,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
// Assert document was created
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
@ -437,14 +436,18 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const url = page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
const { token } = await prisma.recipient.findFirstOrThrow({
where: {
envelope: {
id: documentId,
},
email: 'user1@example.com',
},
});
await page.goto(`/sign/${token}`);
@ -500,7 +503,7 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
recipient: {
email: 'user1@example.com',
},
documentId: Number(document.id),
envelopeId: document.id,
},
});
@ -583,11 +586,11 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
const createdDocument = await prisma.document.findFirst({
const createdDocument = await prisma.envelope.findFirst({
where: { title: documentTitle },
include: { recipients: true },
});
@ -602,13 +605,13 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(recipient).not.toBeNull();
const fields = await prisma.field.findMany({
where: { recipientId: recipient?.id, documentId: createdDocument?.id },
where: { recipientId: recipient?.id, envelopeId: createdDocument?.id },
});
const recipientField = fields[0];
if (i > 0) {
const previousRecipient = await prisma.recipient.findFirst({
where: { email: `user${i}@example.com`, documentId: createdDocument?.id },
where: { email: `user${i}@example.com`, envelopeId: createdDocument?.id },
});
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
@ -636,7 +639,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
// Wait for the document to be signed.
await page.waitForTimeout(10000);
const finalDocument = await prisma.document.findFirst({
const finalDocument = await prisma.envelope.findFirst({
where: { id: createdDocument?.id },
});
@ -648,18 +651,20 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
const { document, recipients } = await seedPendingDocumentWithFullFields({
teamId: team.id,
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
create: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
});
await prisma.documentMeta.update({
where: {
id: document.documentMetaId,
},
data: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});