Merge branch 'main' into feat/public-completed-document-access

This commit is contained in:
ephraimduncan
2026-04-22 18:29:11 +00:00
625 changed files with 48707 additions and 14739 deletions
+1 -1
View File
@@ -1389,7 +1389,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
throw new Error('Invalid page number');
}
const recipient = await prisma.recipient.findFirst({
const recipient = await tx.recipient.findFirst({
where: {
id: Number(recipientId),
envelopeId: envelope.id,
+10 -9
View File
@@ -24,6 +24,7 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { zEmail } from '@documenso/lib/utils/zod';
extendZodWithOpenApi(z);
@@ -150,7 +151,7 @@ export const ZCreateDocumentMutationSchema = z.object({
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
@@ -224,7 +225,7 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
@@ -244,7 +245,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
}),
@@ -299,7 +300,7 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
@@ -326,7 +327,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.array(
z.object({
id: z.number(),
email: z.string().email(),
email: zEmail(),
name: z.string().optional(),
signingOrder: z.number().optional(),
}),
@@ -386,7 +387,7 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
email: zEmail().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
@@ -402,7 +403,7 @@ export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(),
authOptions: z
@@ -437,7 +438,7 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
// !: This handles the fact that we have null documentId's for templates
// !: while we won't need the default we must add it to satisfy typescript
documentId: z.number().nullish().default(-1),
email: z.string().email().min(1),
email: zEmail().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
@@ -576,7 +577,7 @@ export const ZRecipientSchema = z.object({
id: z.number(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
email: z.string().email().min(1),
email: zEmail().min(1),
name: z.string(),
token: z.string(),
signingOrder: z.number().nullish(),
@@ -0,0 +1,468 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import {
seedCompletedDocument,
seedDocuments,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const trpcDocumentSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/document.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as Array<{
title: string;
path: string;
value: string;
}>)
: null,
};
};
// ─── Visibility ──────────────────────────────────────────────────────────────
test.describe('Document Search - Visibility', () => {
test('should respect team document visibility per role', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser();
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.ADMIN },
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MEMBER },
],
});
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
include: { organisationGroup: true },
});
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
where: { organisationId: organisation.id, userId: managerUser.id },
});
await prisma.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
groupId: managerTeamGroup.organisationGroupId,
organisationMemberId: managerOrganisationMember.id,
},
});
await seedDocuments([
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Searchable Document for Everyone',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Document for Managers',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'ADMIN',
title: 'Searchable Document for Admins',
},
},
]);
const testCases = [
{ user: adminUser, visibleDocs: 3 },
{ user: managerUser, visibleDocs: 2 },
{ user: memberUser, visibleDocs: 1 },
];
for (const { user, visibleDocs } of testCases) {
await apiSignin({ page, email: user.email });
const { data } = await trpcDocumentSearch(page, 'Searchable Document');
expect(data).not.toBeNull();
expect(data).toHaveLength(visibleDocs);
await apiSignout({ page });
}
});
test('should respect visibility when searching by recipient email', async ({ page }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: uniqueRecipient } = await seedUser();
await seedDocuments([
{
sender: owner,
recipients: [uniqueRecipient],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
visibility: 'ADMIN',
title: 'Admin Document for Unique Recipient',
},
},
]);
// Admin can find the ADMIN-visibility document by recipient email.
await apiSignin({ page, email: adminUser.email });
const { data: adminData } = await trpcDocumentSearch(page, uniqueRecipient.email);
expect(adminData).not.toBeNull();
expect(adminData).toHaveLength(1);
expect(adminData![0].title).toBe('Admin Document for Unique Recipient');
await apiSignout({ page });
// Member cannot find the ADMIN-visibility document by recipient email.
await apiSignin({ page, email: memberUser.email });
const { data: memberData } = await trpcDocumentSearch(page, uniqueRecipient.email);
expect(memberData).not.toBeNull();
expect(memberData).toHaveLength(0);
await apiSignout({ page });
});
});
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
test.describe('Document Search - Cross-Team Isolation', () => {
test('should not reveal documents from other teams', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Unique Team A Document',
},
},
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
visibility: 'EVERYONE',
title: 'Unique Team B Document',
},
},
]);
await apiSignin({ page, email: memberA.email });
const { data } = await trpcDocumentSearch(page, 'Unique');
expect(data).not.toBeNull();
const titles = data!.map((d) => d.title);
expect(titles).toContain('Unique Team A Document');
expect(titles).not.toContain('Unique Team B Document');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Wildcard Doc A' },
},
]);
await seedDocuments([
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Wildcard Doc B' },
},
]);
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcDocumentSearch(page, '%');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Wildcard Doc B');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "_"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedDocuments([
{
sender: ownerA,
teamId: teamA.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Underscore A' },
},
]);
await seedDocuments([
{
sender: ownerB,
teamId: teamB.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: { title: 'Underscore B' },
},
]);
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcDocumentSearch(page, '_');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Underscore B');
await apiSignout({ page });
});
});
// ─── Recipient Search ────────────────────────────────────────────────────────
test.describe('Document Search - Recipient', () => {
test('should find documents where user is a recipient', async ({ page }) => {
const { team: senderTeam, owner: sender } = await seedTeam();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Recipient Search Test Doc' },
});
await apiSignin({ page, email: recipient.email });
const { data } = await trpcDocumentSearch(page, 'Recipient Search Test');
expect(data).not.toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
const result = data!.find((d) => d.title === 'Recipient Search Test Doc');
expect(result).toBeDefined();
expect(result!.path).toMatch(/^\/sign\/.+/);
await apiSignout({ page });
});
});
// ─── Token Masking ───────────────────────────────────────────────────────────
test.describe('Document Search - Token Masking', () => {
test('should not expose signing token in value field', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: recipient } = await seedUser();
const doc = await seedPendingDocument(owner, team.id, [recipient], {
createDocumentOptions: { title: 'Token Leakage Test Document' },
});
const recipientRecord = await prisma.recipient.findFirstOrThrow({
where: { envelopeId: doc.id, email: recipient.email },
select: { token: true },
});
const signingToken = recipientRecord.token;
// Owner should see the doc but not any signing token.
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Token Leakage Test');
expect(data).not.toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
const result = data!.find((d) => d.title === 'Token Leakage Test Document');
expect(result).toBeDefined();
expect(result!.value).not.toContain(signingToken);
expect(result!.path).not.toContain('/sign/');
await apiSignout({ page });
// Recipient should get their own /sign/ path but token still hidden from value.
await apiSignin({ page, email: recipient.email });
const { data: recipientData } = await trpcDocumentSearch(page, 'Token Leakage Test');
expect(recipientData).not.toBeNull();
expect(recipientData!.length).toBeGreaterThanOrEqual(1);
const recipientResult = recipientData!.find((d) => d.title === 'Token Leakage Test Document');
expect(recipientResult).toBeDefined();
expect(recipientResult!.path).toBe(`/sign/${signingToken}`);
expect(recipientResult!.value).not.toContain(signingToken);
await apiSignout({ page });
});
test('should not expose other recipients signing tokens to the owner', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: recipientA } = await seedUser();
const { user: recipientB } = await seedUser();
const doc = await seedPendingDocument(owner, team.id, [recipientA, recipientB], {
createDocumentOptions: { title: 'Multi Recipient Token Test' },
});
const recipients = await prisma.recipient.findMany({
where: { envelopeId: doc.id },
select: { token: true, email: true },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Multi Recipient Token');
expect(data).not.toBeNull();
const result = data!.find((d) => d.title === 'Multi Recipient Token Test');
expect(result).toBeDefined();
for (const r of recipients) {
expect(result!.value).not.toContain(r.token);
expect(result!.path).not.toContain(r.token);
}
await apiSignout({ page });
});
});
// ─── Filtering ───────────────────────────────────────────────────────────────
test.describe('Document Search - Filtering', () => {
test('should exclude soft-deleted documents', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Active Deletable Document' },
});
const deletedDoc = await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Deleted Deletable Document' },
});
await prisma.envelope.update({
where: { id: deletedDoc.id },
data: { deletedAt: new Date() },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'Deletable Document');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('Active Deletable Document');
await apiSignout({ page });
});
test('should find documents by externalId', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedDocuments([
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: {
title: 'ExternalId Test Doc',
externalId: 'ext-unique-abc-123',
},
},
{
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.DRAFT,
documentOptions: {
title: 'Other Test Doc',
externalId: 'ext-other-xyz',
},
},
]);
await apiSignin({ page, email: owner.email });
const { data } = await trpcDocumentSearch(page, 'ext-unique-abc');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('ExternalId Test Doc');
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Document Search - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcDocumentSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -0,0 +1,259 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
const trpcTemplateSearch = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/template.search?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as Array<{
title: string;
path: string;
value: string;
}>)
: null,
};
};
// ─── Visibility ──────────────────────────────────────────────────────────────
test.describe('Template Search - Visibility', () => {
test('should respect team template visibility per role', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser();
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.ADMIN },
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MEMBER },
],
});
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER },
include: { organisationGroup: true },
});
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
where: { organisationId: organisation.id, userId: managerUser.id },
});
await prisma.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
groupId: managerTeamGroup.organisationGroupId,
organisationMemberId: managerOrganisationMember.id,
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Searchable Template for Everyone',
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Template for Managers',
},
});
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
visibility: 'ADMIN',
title: 'Searchable Template for Admins',
},
});
const testCases = [
{ user: adminUser, visibleTemplates: 3 },
{ user: managerUser, visibleTemplates: 2 },
{ user: memberUser, visibleTemplates: 1 },
];
for (const { user, visibleTemplates } of testCases) {
await apiSignin({ page, email: user.email });
const { data } = await trpcTemplateSearch(page, 'Searchable Template');
expect(data).not.toBeNull();
expect(data).toHaveLength(visibleTemplates);
await apiSignout({ page });
}
});
});
// ─── Cross-Team Isolation ────────────────────────────────────────────────────
test.describe('Template Search - Cross-Team Isolation', () => {
test('should not reveal templates from other teams', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Unique Team A Template',
},
});
await seedBlankTemplate(ownerB, teamB.id, {
createTemplateOptions: {
visibility: 'EVERYONE',
title: 'Unique Team B Template',
},
});
await apiSignin({ page, email: memberA.email });
const { data } = await trpcTemplateSearch(page, 'Unique');
expect(data).not.toBeNull();
const titles = data!.map((d) => d.title);
expect(titles).toContain('Unique Team A Template');
expect(titles).not.toContain('Unique Team B Template');
await apiSignout({ page });
});
test('should not cross team boundaries when searching SQL wildcard "%"', async ({ page }) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const { team: teamB, owner: ownerB } = await seedTeam();
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: { title: 'Wildcard Tpl A' },
});
await seedBlankTemplate(ownerB, teamB.id, {
createTemplateOptions: { title: 'Wildcard Tpl B' },
});
await apiSignin({ page, email: ownerA.email });
const { data } = await trpcTemplateSearch(page, '%');
expect(data).not.toBeNull();
expect(data!.map((d) => d.title)).not.toContain('Wildcard Tpl B');
await apiSignout({ page });
});
});
// ─── Recipient Email Search ──────────────────────────────────────────────────
test.describe('Template Search - Recipient Email', () => {
test('should find templates by recipient email within team but not cross-team', async ({
page,
}) => {
const { team: teamA, owner: ownerA } = await seedTeam();
const adminUserA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
const { team: teamB, owner: ownerB } = await seedTeam();
const { user: uniqueRecipient } = await seedUser();
const template = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: { title: 'Template with Unique Recipient' },
});
await prisma.recipient.create({
data: {
email: uniqueRecipient.email,
name: uniqueRecipient.name ?? '',
token: Math.random().toString().slice(2, 7),
envelopeId: template.id,
},
});
// Team admin can find the template by recipient email.
await apiSignin({ page, email: adminUserA.email });
const { data: adminData } = await trpcTemplateSearch(page, uniqueRecipient.email);
expect(adminData).not.toBeNull();
expect(adminData).toHaveLength(1);
expect(adminData![0].title).toBe('Template with Unique Recipient');
await apiSignout({ page });
// Owner of a different team cannot find it.
await apiSignin({ page, email: ownerB.email });
const { data: otherTeamData } = await trpcTemplateSearch(page, uniqueRecipient.email);
expect(otherTeamData).not.toBeNull();
expect(otherTeamData).toHaveLength(0);
await apiSignout({ page });
});
});
// ─── Filtering ───────────────────────────────────────────────────────────────
test.describe('Template Search - Filtering', () => {
test('should exclude soft-deleted templates', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Active Findable Template' },
});
const deletedTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Deleted Findable Template' },
});
await prisma.envelope.update({
where: { id: deletedTemplate.id },
data: { deletedAt: new Date() },
});
await apiSignin({ page, email: owner.email });
const { data } = await trpcTemplateSearch(page, 'Findable Template');
expect(data).not.toBeNull();
expect(data).toHaveLength(1);
expect(data![0].title).toBe('Active Findable Template');
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Template Search - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcTemplateSearch(page, 'anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -197,7 +197,7 @@ test.describe('Template Field Prefill API v1', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template with prefilled fields
@@ -488,7 +488,7 @@ test.describe('Template Field Prefill API v1', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template without prefilled fields
@@ -209,6 +209,7 @@ test.describe('API V2 Envelopes', () => {
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
},
},
attachments: [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -197,7 +197,7 @@ test.describe('Template Field Prefill API v2', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template with prefilled fields using v2 API
@@ -485,7 +485,7 @@ test.describe('Template Field Prefill API v2', () => {
// 7. Navigate to the template
await page.goto(
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
);
// 8. Create a document from the template without prefilled fields using v2 API
@@ -0,0 +1,519 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-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 { TUpdateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/update-envelope-items.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Update Items Test',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Transition an envelope from DRAFT to PENDING by adding a recipient with a
* signature field and distributing.
*/
const distributeEnvelope = async (
request: APIRequestContext,
authToken: string,
envelopeId: string,
) => {
const recipientEmail = `signer-${Date.now()}@test.documenso.com`;
// Create a SIGNER recipient.
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
email: recipientEmail,
name: 'Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(recipientsRes.ok()).toBeTruthy();
const recipients = (await recipientsRes.json()).data;
// Resolve the envelope item ID.
const envelope = await getEnvelope(request, authToken, envelopeId);
const envelopeItemId = envelope.envelopeItems[0].id;
// Create a SIGNATURE field.
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
data: [
{
recipientId: recipients[0].id,
envelopeItemId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
},
});
expect(fieldsRes.ok()).toBeTruthy();
// Distribute.
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
};
const updateEnvelopeItems = async (
request: APIRequestContext,
authToken: string,
payload: TUpdateEnvelopeItemsRequest,
) => {
return request.post(`${baseUrl}/envelope/item/update-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: payload,
});
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Update envelope items', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-update-items',
expiresIn: null,
}));
});
// -----------------------------------------------------------------------
// DRAFT envelope — full edit allowed
// -----------------------------------------------------------------------
test('should allow updating item title on a DRAFT envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'New Draft Title' }],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('New Draft Title');
});
test('should allow updating item order on a DRAFT envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 5 }],
});
expect(res.ok()).toBeTruthy();
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.order).toBe(5);
});
// -----------------------------------------------------------------------
// PENDING envelope — title-only edit allowed
// -----------------------------------------------------------------------
test('should allow updating item title on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Updated Pending Title' }],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('Updated Pending Title');
});
test('should allow title-only update when order matches existing on a PENDING envelope', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
// Send the same order value that already exists — this should be treated as title-only.
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [
{
envelopeItemId: envelopeItem.id,
title: 'Title With Same Order',
order: envelopeItem.order,
},
],
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).toBe('Title With Same Order');
});
test('should create an ENVELOPE_ITEM_UPDATED audit log when updating item title on PENDING envelope', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const originalTitle = envelopeItem.title;
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Audited Title' }],
});
expect(res.ok()).toBeTruthy();
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'ENVELOPE_ITEM_UPDATED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.envelopeItemId).toBe(envelopeItem.id);
const changes = auditData.changes as Array<{ field: string; from: string; to: string }>;
expect(changes).toHaveLength(1);
expect(changes[0].field).toBe('title');
expect(changes[0].from).toBe(originalTitle);
expect(changes[0].to).toBe('Audited Title');
});
// -----------------------------------------------------------------------
// PENDING envelope — order change blocked
// -----------------------------------------------------------------------
test('should reject order change on a PENDING envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should reject combined title and order change on a PENDING envelope', async ({
request,
}) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [
{
envelopeItemId: envelopeItem.id,
title: 'Should Not Save',
order: 99,
},
],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
// -----------------------------------------------------------------------
// COMPLETED envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a COMPLETED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Transition to COMPLETED directly via database.
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
test('should reject order update on a COMPLETED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.COMPLETED, completedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
// -----------------------------------------------------------------------
// REJECTED envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a REJECTED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Transition to REJECTED directly via database.
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.REJECTED },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
// Verify title was NOT changed.
const dbItem = await prisma.envelopeItem.findUniqueOrThrow({
where: { id: envelopeItem.id },
});
expect(dbItem.title).not.toBe('Should Not Save');
});
test('should reject order update on a REJECTED envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
await prisma.envelope.update({
where: { id: envelope.id },
data: { status: DocumentStatus.REJECTED },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, order: 99 }],
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
// -----------------------------------------------------------------------
// Deleted envelope — all edits blocked
// -----------------------------------------------------------------------
test('should reject title update on a deleted envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
await distributeEnvelope(request, token, envelope.id);
// Soft-delete the envelope.
await prisma.envelope.update({
where: { id: envelope.id },
data: { deletedAt: new Date() },
});
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: 'Should Not Save' }],
});
expect(res.ok()).toBeFalsy();
});
// -----------------------------------------------------------------------
// Validation edge cases
// -----------------------------------------------------------------------
test('should reject an empty title on any envelope', async ({ request }) => {
const envelope = await createEnvelope(request, token);
const envelopeData = await getEnvelope(request, token, envelope.id);
const envelopeItem = envelopeData.envelopeItems[0];
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelope.id,
data: [{ envelopeItemId: envelopeItem.id, title: '' }],
});
expect(res.ok()).toBeFalsy();
});
test('should reject update for an envelope item that does not belong to the envelope', async ({
request,
}) => {
const envelopeA = await createEnvelope(request, token);
const envelopeB = await createEnvelope(request, token);
const envelopeBData = await getEnvelope(request, token, envelopeB.id);
const envelopeBItem = envelopeBData.envelopeItems[0];
// Try to update envelopeB's item via envelopeA's ID.
const res = await updateEnvelopeItems(request, token, {
envelopeId: envelopeA.id,
data: [{ envelopeItemId: envelopeBItem.id, title: 'Cross Envelope' }],
});
expect(res.ok()).toBeFalsy();
});
});
@@ -44,7 +44,7 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
await apiSignin({
page,
email: recipient.email,
email: user.email,
});
await page.keyboard.press('Meta+K');
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
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';
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -108,7 +109,9 @@ test.describe('AutoSave Subject Step', () => {
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
await page
.getByText('Email recipients when the document is completed', { exact: true })
.click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -139,16 +142,20 @@ test.describe('AutoSave Subject Step', () => {
).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
await expect(
page.getByText('Email recipients when a pending document is deleted'),
).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
{
checked: emailSettings?.documentPending,
},
);
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
@@ -167,7 +174,9 @@ test.describe('AutoSave Subject Step', () => {
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
await page
.getByText('Email recipients when the document is completed', { exact: true })
.click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -207,16 +216,20 @@ test.describe('AutoSave Subject Step', () => {
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
await expect(
page.getByText('Email recipients when a pending document is deleted'),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked(
{
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
},
);
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
}
// Complete the document
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
@@ -9,6 +9,7 @@ import {
import { DateTime } from 'luxon';
import path from 'node:path';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 100,
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 500,
y: 200,
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
}
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100 * i,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,442 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
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 { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-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 { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a PENDING envelope via the V2 API with a single SIGNER recipient
* and a SIGNATURE field, then distribute it.
*
* Returns the envelope ID, recipient email, team URL, and user info for navigation.
*/
const createPendingEnvelopeViaApi = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'e2e-resend-test',
expiresIn: null,
});
const recipientEmail = `resend-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Resend Test ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Resend Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
// 5. Distribute the envelope.
const distributeRes = await fetch(`${V2_API_BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: createResponse.id,
} satisfies TDistributeEnvelopeRequest),
});
expect(distributeRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('send document via email', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
await expect(surface.root.getByText('1 Field')).toBeVisible();
// Navigate back to the recipients step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Send Document" sidebar action.
await page.locator('button[title="Send Envelope"]').click();
// The distribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
// Click Send.
await page.getByRole('button', { name: 'Send' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Envelope distributed');
// Assert the document status was changed in the database.
const updatedEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: surface.envelopeId },
});
expect(updatedEnvelope.status).toBe(DocumentStatus.PENDING);
});
test('send document shows validation when signers lack signature fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a SIGNER recipient via the UI — but do NOT place any fields.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Click the "Send Document" sidebar action without placing signature fields.
await page.locator('button[title="Send Envelope"]').click();
// The distribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Send Document' })).toBeVisible();
// The validation warning should be shown instead of the send form.
await expect(
page.getByText('The following signers are missing signature fields'),
).toBeVisible();
// The "Send" button should not be visible since we're in validation mode.
await expect(page.getByRole('button', { name: 'Send', exact: true })).not.toBeVisible();
// Close the dialog.
await page.getByRole('button', { name: 'Close' }).click();
// The dialog should be closed.
await expect(
page.getByText('The following signers are missing signature fields'),
).not.toBeVisible();
});
test('resend document sends reminder', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createPendingEnvelopeViaApi();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Resend Document" sidebar action.
await page.locator('button[title="Resend Envelope"]').click();
// The redistribute dialog should appear.
await expect(page.getByRole('heading', { name: 'Resend Document' })).toBeVisible();
// The unsigned recipient should be listed.
await expect(page.getByText(recipientEmail)).toBeVisible();
// Select the recipient checkbox.
await page.getByRole('checkbox').first().click();
// Click "Send reminder".
await page.getByRole('button', { name: 'Send reminder' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Envelope resent');
// Verify a resend audit log entry was created in the database.
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId,
type: 'EMAIL_SENT',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
expect((auditLog!.data as Record<string, unknown>).isResending).toBe(true);
expect((auditLog!.data as Record<string, unknown>).recipientEmail).toBe(recipientEmail);
});
test('duplicate document', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Duplicate Document" sidebar action.
await page.locator('button[title="Duplicate Envelope"]').click();
// The duplicate dialog should appear.
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Click "Duplicate".
await page.getByRole('button', { name: 'Duplicate' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Document Duplicated');
// The page should have navigated to the new document's edit page.
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// Verify a new envelope was created in the database.
const envelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'DOCUMENT',
},
});
// Should have original + duplicate.
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
// Click the "Download PDF" sidebar action.
await page.locator('button[title="Download PDF"]').click();
// The download dialog should appear.
await expect(page.getByRole('heading', { name: 'Download Files' })).toBeVisible();
await expect(page.getByText('Select the files you would like to download.')).toBeVisible();
// At least one envelope item with an "Original" download button should be listed.
await expect(page.getByRole('button', { name: 'Original' }).first()).toBeVisible();
});
test('delete draft document', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const documentId = surface.envelopeId;
// Click the "Delete Document" sidebar action.
await page.locator('button[title="Delete Envelope"]').click();
// The delete dialog should appear.
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
// For DRAFT documents no confirmation input is needed, just click "Delete".
await page.getByRole('button', { name: 'Delete' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Document deleted');
// The page should navigate back to the documents list.
await expect(page).toHaveURL(/\/documents$/);
// Verify the document was deleted from the database.
const deletedDocument = await prisma.envelope.findUnique({
where: { id: documentId },
});
expect(deletedDocument).toBeNull();
});
});
test.describe('template editor', () => {
test('create and manage direct link', async ({ page }) => {
const { user, team } = await seedUser();
// seedTemplate creates a template with a SIGNER recipient.
const template = await seedTemplate({
title: `E2E Direct Link Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Direct Link" sidebar action.
await page.locator('button[title="Direct Link"]').click();
// The onboarding dialog should appear.
await expect(page.getByRole('heading', { name: 'Create Direct Signing Link' })).toBeVisible();
// Click "Enable direct link signing" to proceed.
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
// Select recipient step: click "Create one automatically".
await page.getByRole('button', { name: 'Create one automatically' }).click();
// Manage step should appear.
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
// The shareable link input should be present.
await expect(page.locator('#copy-direct-link')).toBeVisible();
// Click "Save" to persist the toggle state.
await page.getByRole('button', { name: 'Save' }).click();
// Assert success toast.
await expectToastTextToBeVisible(page, 'Success');
// Verify a TemplateDirectLink was created and enabled in the database.
const directLink = await prisma.templateDirectLink.findFirst({
where: { envelopeId: template.id },
});
expect(directLink).not.toBeNull();
expect(directLink!.enabled).toBe(true);
});
test('duplicate template', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
// Click the "Duplicate Template" sidebar action.
await page.locator('button[title="Duplicate Envelope"]').click();
// The duplicate dialog should appear.
await expect(page.getByRole('heading', { name: 'Duplicate Template' })).toBeVisible();
// Click "Duplicate".
await page.getByRole('button', { name: 'Duplicate' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Duplicated');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify a new envelope was created in the database.
const envelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
});
// Should have original + duplicate.
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
});
@@ -0,0 +1,329 @@
import { type Page, expect, test } from '@playwright/test';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const TEST_ATTACHMENTS = {
first: {
label: 'E2E First Attachment',
url: 'https://example.com/first-attachment',
},
second: {
label: 'E2E Second Attachment',
url: 'https://example.com/second-attachment',
},
third: {
label: 'E2E Third Attachment',
url: 'https://example.com/third-attachment',
},
};
type AttachmentFlowResult = {
externalId: string;
expectedAttachments: Array<{ label: string; url: string }>;
deletedAttachment: { label: string; url: string };
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const openAttachmentsPopover = async (root: Page) => {
await root.getByRole('button', { name: 'Attachments' }).click();
await expect(root.getByRole('heading', { name: 'Attachments', level: 4 })).toBeVisible();
};
const addAttachment = async (root: Page, { label, url }: { label: string; url: string }) => {
await root.getByRole('button', { name: 'Add Attachment' }).click();
await root.getByPlaceholder('Label').fill(label);
await root.getByPlaceholder('URL').fill(url);
await root.getByRole('button', { name: 'Add', exact: true }).click();
};
const getAttachmentItems = (root: Page) => root.locator('.rounded-md.border.border-border.p-2');
const getAttachmentDeleteButtons = (root: Page) => getAttachmentItems(root).locator('button');
const assertAttachmentVisibleInPopover = async (
root: Page,
{ label, url }: { label: string; url: string },
) => {
const items = getAttachmentItems(root);
const matchingItem = items.filter({ hasText: label });
await expect(matchingItem).toBeVisible();
await expect(matchingItem.locator(`a[href="${url}"]`)).toBeVisible();
};
const assertAttachmentNotVisibleInPopover = async (root: Page, label: string) => {
await expect(getAttachmentItems(root).filter({ hasText: label })).toHaveCount(0);
};
const assertAttachmentCount = async (root: Page, count: number) => {
const button = root.getByRole('button', { name: 'Attachments' });
if (count > 0) {
await expect(button).toContainText(`(${count})`);
} else {
await expect(button).not.toContainText('(');
}
};
const assertAttachmentsInDatabase = async ({
externalId,
surface,
expectedAttachments,
deletedLabel,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
expectedAttachments: Array<{ label: string; url: string }>;
deletedLabel?: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeAttachments: {
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.envelopeAttachments).toHaveLength(expectedAttachments.length);
for (const expected of expectedAttachments) {
const found = envelope.envelopeAttachments.find((a) => a.label === expected.label);
expect(found).toBeDefined();
expect(found!.data).toBe(expected.url);
expect(found!.type).toBe('link');
}
if (deletedLabel) {
expect(envelope.envelopeAttachments.some((a) => a.label === deletedLabel)).toBe(false);
}
};
const runAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
await openAttachmentsPopover(surface.root);
// Create first attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
await assertAttachmentCount(surface.root, 1);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first],
});
// Create second attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentCount(surface.root, 2);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second],
});
// Create third attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 3);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.first, TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
});
// Delete first attachment.
await getAttachmentDeleteButtons(surface.root).first().click();
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 2);
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedLabel: TEST_ATTACHMENTS.first.label,
});
return {
externalId,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedAttachment: TEST_ATTACHMENTS.first,
};
};
const runEmbeddedAttachmentFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<AttachmentFlowResult> => {
const externalId = `e2e-attachments-${nanoid()}`;
await updateExternalId(surface, externalId);
await openAttachmentsPopover(surface.root);
// Create first attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.first);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.first);
await assertAttachmentCount(surface.root, 1);
// Create second attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.second);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentCount(surface.root, 2);
// Create third attachment.
await addAttachment(surface.root, TEST_ATTACHMENTS.third);
await expectToastTextToBeVisible(surface.root, 'Attachment added successfully.');
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 3);
// Delete first attachment.
await getAttachmentDeleteButtons(surface.root).first().click();
await expectToastTextToBeVisible(surface.root, 'Attachment removed successfully.');
await expect(getAttachmentItems(surface.root)).toHaveCount(2);
await assertAttachmentNotVisibleInPopover(surface.root, TEST_ATTACHMENTS.first.label);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.second);
await assertAttachmentVisibleInPopover(surface.root, TEST_ATTACHMENTS.third);
await assertAttachmentCount(surface.root, 2);
return {
externalId,
expectedAttachments: [TEST_ATTACHMENTS.second, TEST_ATTACHMENTS.third],
deletedAttachment: TEST_ATTACHMENTS.first,
};
};
const assertAttachmentsPersistedInDatabase = async ({
surface,
externalId,
expectedAttachments,
deletedAttachment,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedAttachments: AttachmentFlowResult['expectedAttachments'];
deletedAttachment: AttachmentFlowResult['deletedAttachment'];
}) => {
await assertAttachmentsInDatabase({
externalId,
surface,
expectedAttachments,
deletedLabel: deletedAttachment.label,
});
};
test.describe('document editor', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAttachmentFlow(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAttachmentFlow(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-attachments',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-attachments.pdf');
const result = await runEmbeddedAttachmentFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('add, verify and delete attachments', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-attachments',
});
const result = await runEmbeddedAttachmentFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAttachmentsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,436 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
createEmbeddedEnvelopeCreateHash,
getEnvelopeEditorSettingsTrigger,
openEmbeddedEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const TEST_CSS_VARS = {
background: '#ff0000',
primary: '#00ff00',
radius: '1rem',
};
/**
* A unique CSS selector used for asserting raw CSS injection.
*/
const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
/**
* Expected HSL values after conversion by `toNativeCssVars`:
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
*/
const EXPECTED_CSS_VARS = {
'--background': '0 100 50',
'--primary': '120 100 50',
'--radius': '1rem',
};
const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: { ownerUserId: userId },
include: { organisationClaim: true },
});
await prisma.organisationClaim.update({
where: { id: organisation.organisationClaim.id },
data: {
flags: {
allowLegacyEnvelopes: true,
embedAuthoringWhiteLabel: true,
},
},
});
};
/**
* The default background color from the theme before any CSS injection.
*
* The theme default `--background: 0 0% 100%` resolves to hsl(0, 0%, 100%) which is white.
*/
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
/**
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
*/
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
const assertCssNotInjected = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const cssState = await page.evaluate(() => {
const rootStyle = document.documentElement.style;
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
return {
background: rootStyle.getPropertyValue('--background'),
primary: rootStyle.getPropertyValue('--primary'),
radius: rootStyle.getPropertyValue('--radius'),
bodyBgColor,
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
el.innerHTML.includes('.e2e-css-test-marker'),
),
};
});
// CSS custom properties should not be set on the inline style.
expect(cssState.background).toBe('');
expect(cssState.primary).toBe('');
expect(cssState.radius).toBe('');
// No raw CSS style tag should be injected.
expect(cssState.hasInjectedStyle).toBe(false);
// The body should still use the default theme background color.
expect(cssState.bodyBgColor).toBe(DEFAULT_BODY_BG_COLOR);
};
const assertCssInjected = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const cssState = await page.evaluate(() => {
const rootStyle = document.documentElement.style;
const bodyBgColor = window.getComputedStyle(document.body).backgroundColor;
return {
background: rootStyle.getPropertyValue('--background'),
primary: rootStyle.getPropertyValue('--primary'),
radius: rootStyle.getPropertyValue('--radius'),
bodyBgColor,
hasInjectedStyle: Array.from(document.head.querySelectorAll('style')).some((el) =>
el.innerHTML.includes('.e2e-css-test-marker'),
),
};
});
// CSS custom properties should be set to the expected HSL values.
expect(cssState.background).toBe(EXPECTED_CSS_VARS['--background']);
expect(cssState.primary).toBe(EXPECTED_CSS_VARS['--primary']);
expect(cssState.radius).toBe(EXPECTED_CSS_VARS['--radius']);
// Raw CSS style tag should be injected.
expect(cssState.hasInjectedStyle).toBe(true);
// The body background should reflect the injected --background value (red).
expect(cssState.bodyBgColor).toBe(INJECTED_BODY_BG_COLOR);
};
const assertDarkModeDisabled = async (surface: TEnvelopeEditorSurface) => {
const { root: page } = surface;
const hasDarkModeDisabled = await page.evaluate(() =>
document.documentElement.classList.contains('dark-mode-disabled'),
);
expect(hasDarkModeDisabled).toBe(true);
};
/**
* Open an embedded create editor with a pre-seeded user. This is needed for folderId
* tests where the folder must exist before the hash is built.
*/
const openEmbeddedCreateWithUser = async (
page: Page,
user: { id: number; email: string; name: string | null },
team: { id: number },
options: { folderId?: string; tokenNamePrefix?: string },
): Promise<TEnvelopeEditorSurface> => {
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${options.tokenNamePrefix ?? 'e2e-embed-folder'}-document`,
expiresIn: null,
});
// Exchange API token for presign token.
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {},
});
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
}
const presignToken = data.token;
if (typeof presignToken !== 'string' || presignToken.length === 0) {
throw new Error(`Unexpected presign response: ${JSON.stringify(data)}`);
}
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType: 'DOCUMENT',
folderId: options.folderId,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(presignToken)}#${hash}`,
);
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
/**
* Helper to set an external ID on the envelope via the settings dialog so we can
* look it up in the database after creation.
*/
const setExternalIdViaSettings = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await getEnvelopeEditorSettingsTrigger(surface.root).click();
await expect(surface.root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
};
/**
* Minimal setup for an embedded create flow: upload a PDF and add a recipient
* so the "Create Document" button works.
*/
const setupMinimalEnvelope = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await addEnvelopeItemPdf(surface.root);
await setRecipientEmail(surface.root, 0, `${nanoid()}@test.documenso.com`);
await setExternalIdViaSettings(surface, externalId);
};
/**
* Click "Create Document" and expect a failure toast instead of the success heading.
*/
const expectCreateToFail = async (surface: TEnvelopeEditorSurface) => {
const actionButtonName =
surface.envelopeType === 'DOCUMENT' ? 'Create Document' : 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
await expectToastTextToBeVisible(surface.root, 'Failed to create document');
};
test.describe('embedded create - folderId', () => {
test('creates envelope in the specified folder when folderId is provided', async ({ page }) => {
const { user, team } = await seedUser();
const folder = await prisma.folder.create({
data: {
name: 'E2E Document Folder',
teamId: team.id,
userId: user.id,
type: 'DOCUMENT',
},
});
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: folder.id,
tokenNamePrefix: 'e2e-embed-folder',
});
const externalId = `e2e-folder-create-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await persistEmbeddedEnvelope(surface);
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
},
});
expect(envelope.folderId).toBe(folder.id);
});
test('creates envelope in root folder when no folderId is provided', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-folder-none',
});
const externalId = `e2e-folder-root-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await persistEmbeddedEnvelope(surface);
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
},
});
expect(envelope.folderId).toBeNull();
});
test('rejects creation when folderId has wrong folder type', async ({ page }) => {
const { user, team } = await seedUser();
// Create a TEMPLATE folder but attempt to create a DOCUMENT envelope in it.
const templateFolder = await prisma.folder.create({
data: {
name: 'E2E Template Folder',
teamId: team.id,
userId: user.id,
type: 'TEMPLATE',
},
});
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: templateFolder.id,
tokenNamePrefix: 'e2e-embed-folder-wrong-type',
});
const externalId = `e2e-folder-wrong-type-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
test('rejects creation when folderId belongs to another team', async ({ page }) => {
// Create the user who will use the embedded editor.
const { user, team } = await seedUser();
// Create a second user/team that owns the folder.
const { user: otherUser, team: otherTeam } = await seedUser();
const otherTeamFolder = await prisma.folder.create({
data: {
name: 'E2E Other Team Folder',
teamId: otherTeam.id,
userId: otherUser.id,
type: 'DOCUMENT',
},
});
// Open the embedded editor for the first user but pass the folder from the other team.
const surface = await openEmbeddedCreateWithUser(page, user, team, {
folderId: otherTeamFolder.id,
tokenNamePrefix: 'e2e-embed-folder-no-perm',
});
const externalId = `e2e-folder-no-perm-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
test('rejects creation when folderId does not exist', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-folder-nonexistent',
folderId: 'nonexistent-folder-id',
});
const externalId = `e2e-folder-nonexistent-${nanoid()}`;
await setupMinimalEnvelope(surface, externalId);
await expectCreateToFail(surface);
// Verify no envelope was created with this externalId.
const count = await prisma.envelope.count({ where: { externalId } });
expect(count).toBe(0);
});
});
test.describe('embedded create', () => {
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
mode: 'create',
tokenNamePrefix: 'e2e-embed-css',
css: TEST_RAW_CSS,
cssVars: TEST_CSS_VARS,
darkModeDisabled: true,
});
// darkModeDisabled is applied regardless of the flag.
await assertDarkModeDisabled(surface);
// Flag is disabled by default so CSS should NOT be injected.
await assertCssNotInjected(surface);
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
await enableEmbedAuthoringWhiteLabel(surface.userId);
// Reload the page to re-run the layout loader with the updated claim.
await page.reload();
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// CSS should now be injected.
await assertCssInjected(surface);
// darkModeDisabled should still be applied after reload.
await assertDarkModeDisabled(surface);
});
});
test.describe('embedded edit', () => {
test('cssVars and css respect embedAuthoringWhiteLabel flag', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-css',
css: TEST_RAW_CSS,
cssVars: TEST_CSS_VARS,
darkModeDisabled: true,
});
// darkModeDisabled is applied regardless of the flag.
await assertDarkModeDisabled(surface);
// Flag is disabled by default so CSS should NOT be injected.
await assertCssNotInjected(surface);
// Enable the embedAuthoringWhiteLabel flag on the organisation claim.
await enableEmbedAuthoringWhiteLabel(surface.userId);
// Reload the page to re-run the layout loader with the updated claim.
await page.reload();
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// CSS should now be injected.
await assertCssInjected(surface);
// darkModeDisabled should still be applied after reload.
await assertDarkModeDisabled(surface);
});
});
@@ -0,0 +1,852 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientRemoveButtons,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
recipientEmail: string;
};
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-field-recipient@documenso.com',
name: 'Embedded Field Recipient',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
return surface.userEmail;
};
type FieldButtonName =
| 'Signature'
| 'Email'
| 'Name'
| 'Initials'
| 'Date'
| 'Text'
| 'Number'
| 'Radio'
| 'Checkbox'
| 'Dropdown';
const placeFieldOnPdf = async (
root: Page,
fieldName: FieldButtonName,
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const selectRecipientInFieldsStep = async (root: Page, recipientIdentifier: string) => {
await root.locator('button[role="combobox"]').click();
await root.getByText(recipientIdentifier).click();
};
const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, force: true });
};
const runAddAndPersistSignatureTextFields = async (
surface: TEnvelopeEditorSurface,
): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(1);
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(2);
await clickEnvelopeEditorStep(surface.root, 'upload');
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'addFields');
await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
expect(fieldCount).toBe(2);
return {
externalId,
recipientEmail,
};
};
const getFieldMetaType = (fieldMeta: unknown) => {
if (!isRecord(fieldMeta)) {
return null;
}
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const assertFieldsPersistedInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: {
createdAt: 'desc',
},
include: {
fields: true,
recipients: true,
},
});
const recipient = envelope.recipients.find(
(currentRecipient) => currentRecipient.email === recipientEmail,
);
expect(recipient).toBeDefined();
const fieldTypes = envelope.fields.map((field) => field.type).sort();
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
expect(envelope.fields).toHaveLength(2);
expect(fieldTypes).toEqual(expectedFieldTypes);
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
};
// --- Multi-recipient field flow ---
type TMultiRecipientFlowResult = {
externalId: string;
firstRecipientEmail: string;
secondRecipientEmail: string;
};
const MULTI_RECIPIENT_VALUES = {
secondSigner: {
email: 'second-signer@test.documenso.com',
name: 'Second Signer',
},
};
const runMultiRecipientFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TMultiRecipientFlowResult> => {
const externalId = `e2e-multi-recip-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
// Add two recipients.
let firstRecipientEmail: string;
if (surface.isEmbedded) {
await setRecipientEmail(root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
firstRecipientEmail = TEST_FIELD_VALUES.embeddedRecipient.email;
} else {
await clickAddMyselfButton(root);
firstRecipientEmail = surface.userEmail;
}
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.email);
await setRecipientName(root, 1, MULTI_RECIPIENT_VALUES.secondSigner.name);
// Navigate to fields step.
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(0);
// Place Signature for recipient #1 (auto-selected).
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Switch recipient and place text field for recipient #2.
await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email);
await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 });
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Navigate away and back to ensure fields are persisted in the UI.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Phase 2: cascade deletion — go back to recipients and remove the second one.
await clickEnvelopeEditorStep(root, 'upload');
await expect(getRecipientEmailInputs(root)).toHaveCount(2);
await getRecipientRemoveButtons(root).nth(1).click();
await expect(getRecipientEmailInputs(root)).toHaveCount(1);
// Go back to fields and verify cascade removal.
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
return {
externalId,
firstRecipientEmail,
secondRecipientEmail: MULTI_RECIPIENT_VALUES.secondSigner.email,
};
};
const assertMultiRecipientCascadePersistedInDatabase = async ({
surface,
externalId,
firstRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
firstRecipientEmail: string;
secondRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true, recipients: true },
});
// After cascade deletion, only one recipient and one field should remain.
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe(firstRecipientEmail);
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
expect(envelope.fields[0].recipientId).toBe(envelope.recipients[0].id);
};
// --- All 10 field types flow ---
type TAllFieldTypesFlowResult = {
externalId: string;
};
const runAllFieldTypesFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TAllFieldTypesFlowResult> => {
const externalId = `e2e-all-fields-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place and configure each field type immediately after placement.
// After placeFieldOnPdf, the sidebar shows the field's config form (field is selected in React state).
// 1. Signature: place and set fontSize to 24.
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 50 });
await root.locator('[data-testid="field-form-fontSize"]').fill('24');
// 2. Email: place and set textAlign to center.
await placeFieldOnPdf(root, 'Email', { x: 120, y: 100 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Center' }).click();
// 3. Name: place and set textAlign to right.
await placeFieldOnPdf(root, 'Name', { x: 120, y: 150 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Right' }).click();
// 4. Initials: place and set fontSize to 16.
await placeFieldOnPdf(root, 'Initials', { x: 120, y: 200 });
await root.locator('[data-testid="field-form-fontSize"]').fill('16');
// 5. Date: place and set textAlign to center.
await placeFieldOnPdf(root, 'Date', { x: 120, y: 250 });
await root.locator('[data-testid="field-form-textAlign"]').click();
await root.getByRole('option', { name: 'Center' }).click();
// 6. Text: place and configure label, placeholder, text, characterLimit, required.
await placeFieldOnPdf(root, 'Text', { x: 120, y: 300 });
await root.locator('[data-testid="field-form-label"]').fill('Test Label');
await root.locator('[data-testid="field-form-placeholder"]').fill('Enter text here');
await root.locator('[data-testid="field-form-text"]').fill('Default text value');
await root.locator('[data-testid="field-form-characterLimit"]').fill('100');
await root.locator('[data-testid="field-form-required"]').click();
// 7. Number: place and configure label, placeholder, numberFormat, minValue, maxValue, required.
await placeFieldOnPdf(root, 'Number', { x: 120, y: 350 });
await root.locator('[data-testid="field-form-label"]').fill('Amount');
await root.locator('[data-testid="field-form-placeholder"]').fill('0.00');
await root.locator('[data-testid="field-form-numberFormat"]').click();
await root.getByRole('option', { name: '123,456,789.00' }).click();
await root.locator('[data-testid="field-form-minValue"]').fill('0');
await root.locator('[data-testid="field-form-maxValue"]').fill('1000');
await root.locator('[data-testid="field-form-required"]').click();
// 8. Radio: place and configure two options, pre-select first, set direction to horizontal.
await placeFieldOnPdf(root, 'Radio', { x: 120, y: 400 });
// The first option already exists with default value "Default value". Fill it.
await root.locator('[data-testid="field-form-values-0-value"]').fill('Option A');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Option B');
// Pre-select the first option (click its checkbox).
await root.locator('[data-testid="field-form-values-0-checked"]').click();
// Set direction to horizontal.
await root.locator('[data-testid="field-form-direction"]').click();
await root.getByRole('option', { name: 'Horizontal' }).click();
// 9. Checkbox: place and configure two options, check both, set validation rule.
await placeFieldOnPdf(root, 'Checkbox', { x: 120, y: 450 });
// Fill first option value.
await root.locator('[data-testid="field-form-values-0-value"]').fill('Check A');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Check B');
// Check both options (click their checkboxes).
await root.locator('[data-testid="field-form-values-0-checked"]').click();
await root.locator('[data-testid="field-form-values-1-checked"]').click();
// Set validation: "Select at least" 1.
await root.locator('[data-testid="field-form-validationRule"]').click();
await root.getByRole('option', { name: 'Select at least' }).click();
// Set validation length to 1.
await root.locator('[data-testid="field-form-validationLength"]').click();
await root.getByRole('option', { name: '1', exact: true }).click();
// 10. Dropdown: place and configure two options, set default value.
await placeFieldOnPdf(root, 'Dropdown', { x: 120, y: 500 });
// First option already has "Option 1". Change it to "Red".
await root.locator('[data-testid="field-form-values-0-value"]').fill('Red');
// Add a second option.
await root.locator('[data-testid="field-form-values-add"]').click();
await root.locator('[data-testid="field-form-values-1-value"]').clear();
await root.locator('[data-testid="field-form-values-1-value"]').fill('Blue');
// Set default value to "Red".
await root.locator('[data-testid="field-form-defaultValue"]').click();
await root.getByRole('option', { name: 'Red' }).click();
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(10);
// Wait briefly for auto-save to fire on the last configured field.
await root.waitForTimeout(500);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(10);
return { externalId };
};
const assertAllFieldTypesPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
expect(envelope.fields).toHaveLength(10);
const fieldsByType = new Map(envelope.fields.map((f) => [f.type, f]));
// Helper to safely access fieldMeta as a record.
const meta = (type: FieldType): Record<string, unknown> => {
const field = fieldsByType.get(type);
expect(field).toBeDefined();
const fieldMeta = field!.fieldMeta;
expect(typeof fieldMeta).toBe('object');
expect(fieldMeta).not.toBeNull();
return fieldMeta as Record<string, unknown>;
};
// SIGNATURE
expect(meta(FieldType.SIGNATURE).type).toBe('signature');
expect(meta(FieldType.SIGNATURE).fontSize).toBe(24);
// EMAIL
expect(meta(FieldType.EMAIL).type).toBe('email');
expect(meta(FieldType.EMAIL).textAlign).toBe('center');
// NAME
expect(meta(FieldType.NAME).type).toBe('name');
expect(meta(FieldType.NAME).textAlign).toBe('right');
// INITIALS
expect(meta(FieldType.INITIALS).type).toBe('initials');
expect(meta(FieldType.INITIALS).fontSize).toBe(16);
// DATE
expect(meta(FieldType.DATE).type).toBe('date');
expect(meta(FieldType.DATE).textAlign).toBe('center');
// TEXT
expect(meta(FieldType.TEXT).type).toBe('text');
expect(meta(FieldType.TEXT).label).toBe('Test Label');
expect(meta(FieldType.TEXT).placeholder).toBe('Enter text here');
expect(meta(FieldType.TEXT).text).toBe('Default text value');
expect(meta(FieldType.TEXT).characterLimit).toBe(100);
expect(meta(FieldType.TEXT).required).toBe(true);
// NUMBER
expect(meta(FieldType.NUMBER).type).toBe('number');
expect(meta(FieldType.NUMBER).label).toBe('Amount');
expect(meta(FieldType.NUMBER).placeholder).toBe('0.00');
expect(meta(FieldType.NUMBER).numberFormat).toBe('123,456,789.00');
expect(meta(FieldType.NUMBER).minValue).toBe(0);
expect(meta(FieldType.NUMBER).maxValue).toBe(1000);
expect(meta(FieldType.NUMBER).required).toBe(true);
// RADIO
expect(meta(FieldType.RADIO).type).toBe('radio');
expect(meta(FieldType.RADIO).direction).toBe('horizontal');
const radioValues = meta(FieldType.RADIO).values as Array<{
value: string;
checked: boolean;
}>;
expect(radioValues).toHaveLength(2);
expect(radioValues[0].value).toBe('Option A');
expect(radioValues[0].checked).toBe(true);
expect(radioValues[1].value).toBe('Option B');
expect(radioValues[1].checked).toBe(false);
// CHECKBOX
expect(meta(FieldType.CHECKBOX).type).toBe('checkbox');
expect(meta(FieldType.CHECKBOX).validationRule).toBe('Select at least');
expect(meta(FieldType.CHECKBOX).validationLength).toBe(1);
const checkboxValues = meta(FieldType.CHECKBOX).values as Array<{
value: string;
checked: boolean;
}>;
expect(checkboxValues).toHaveLength(2);
expect(checkboxValues[0].value).toBe('Check A');
expect(checkboxValues[0].checked).toBe(true);
expect(checkboxValues[1].value).toBe('Check B');
expect(checkboxValues[1].checked).toBe(true);
// DROPDOWN
expect(meta(FieldType.DROPDOWN).type).toBe('dropdown');
expect(meta(FieldType.DROPDOWN).defaultValue).toBe('Red');
const dropdownValues = meta(FieldType.DROPDOWN).values as Array<{ value: string }>;
expect(dropdownValues).toHaveLength(2);
expect(dropdownValues[0].value).toBe('Red');
expect(dropdownValues[1].value).toBe('Blue');
};
// --- Duplicate and delete fields flow ---
type TDuplicateDeleteFlowResult = {
externalId: string;
};
const runDuplicateDeleteFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<TDuplicateDeleteFlowResult> => {
const externalId = `e2e-dup-del-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place a Signature field.
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Select the field on canvas to show the action toolbar.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await expect(root.locator('button[title="Duplicate"]')).toBeVisible();
// Duplicate the field.
await root.locator('button[title="Duplicate"]').click();
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Navigate away and back to persist changes.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(2);
// Select a field and delete it via the Remove button.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCount).toBe(1);
return { externalId };
};
const assertDuplicateDeleteFieldPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// After duplicating (2 fields) then deleting one, exactly 1 SIGNATURE field should remain.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMultiRecipientFieldFlow(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runDuplicateDeleteFieldFlow(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMultiRecipientFieldFlow(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runDuplicateDeleteFieldFlow(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runAddAndPersistSignatureTextFields(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-multi-recip',
});
const result = await runMultiRecipientFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-dup-del',
});
const result = await runDuplicateDeleteFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-all-fields',
});
const result = await runAllFieldTypesFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runAddAndPersistSignatureTextFields(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('multi-recipient field placement, switching, and cascade deletion', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-multi-recip',
});
const result = await runMultiRecipientFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertMultiRecipientCascadePersistedInDatabase({
surface,
...result,
});
});
test('duplicate and delete fields via canvas action toolbar', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-dup-del',
});
const result = await runDuplicateDeleteFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertDuplicateDeleteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-all-fields',
});
const result = await runAllFieldTypesFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertAllFieldTypesPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,321 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
// --- Shared helpers ---
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const getEditButton = (root: Page, index: number) =>
root.locator('[data-testid^="envelope-item-edit-button-"]').nth(index);
const getEditDialog = (root: Page) => root.getByRole('dialog');
const getEditDialogTitleInput = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-title-input"]');
const getEditDialogDropzone = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-dropzone"]');
const getEditDialogSelectedFile = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-selected-file"]');
const getEditDialogClearFileButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-clear-file"]');
const getEditDialogUpdateButton = (root: Page) =>
root.locator('[data-testid="envelope-item-edit-update-button"]');
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
const navigateToFieldsPage = async (surface: TEnvelopeEditorSurface) => {
// Set up a recipient first so the fields page is functional.
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, `test-${nanoid(4)}@example.com`);
await setRecipientName(surface.root, 0, 'Test User');
} else {
await clickAddMyselfButton(surface.root);
}
await clickEnvelopeEditorStep(surface.root, 'addFields');
// Wait for the file selector to be visible on the fields page.
await expect(getEditButton(surface.root, 0)).toBeAttached({ timeout: 10000 });
};
const openEditDialogOnFieldsPage = async (root: Page, index = 0) => {
// Hover to reveal the edit button, then click it.
const editButton = getEditButton(root, index);
await editButton.click({ force: true });
await expect(getEditDialog(root)).toBeVisible();
};
// --- Flows ---
const runRenameFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-rename-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'rename-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// Open edit dialog and change the title.
await openEditDialogOnFieldsPage(surface.root);
const titleInput = getEditDialogTitleInput(surface.root);
await expect(titleInput).toBeVisible();
await titleInput.clear();
await titleInput.fill('Renamed Document');
// Update button should be disabled without a replacement file.
await expect(getEditDialogUpdateButton(surface.root)).toBeDisabled();
// A replacement file is required for the Update button to be enabled.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'example.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 10000 });
return { externalId };
};
const runReplacePdfFlow = async (surface: TEnvelopeEditorSurface) => {
const externalId = `e2e-edit-replace-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'replace-test.pdf');
}
await updateExternalId(surface, externalId);
await navigateToFieldsPage(surface);
// First, assert the page count is 1 (example.pdf has 1 page).
await assertPdfPageCount(surface.root, 1);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a multi-page PDF via the dropzone.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'field-font-alignment.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
// Verify file is shown in the selected file display.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
// Click Update.
await getEditDialogUpdateButton(surface.root).click();
// Dialog should close.
await expect(getEditDialog(surface.root)).not.toBeVisible({ timeout: 15000 });
// After replacement, the page count should be 3 (field-font-alignment.pdf has 3 pages).
await assertPdfPageCount(surface.root, 3);
return { externalId };
};
const runClearFileSelectionFlow = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'clear-file-test.pdf');
}
await navigateToFieldsPage(surface);
// Open edit dialog.
await openEditDialogOnFieldsPage(surface.root);
// Select a file.
const dropzone = getEditDialogDropzone(surface.root);
await expect(dropzone).toBeVisible();
const fileInput = dropzone.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'to-be-cleared.pdf',
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
// Verify file appears.
await expect(getEditDialogSelectedFile(surface.root)).toBeVisible();
await expect(getEditDialogDropzone(surface.root)).not.toBeVisible();
// Click X to clear.
await getEditDialogClearFileButton(surface.root).click();
// Dropzone should reappear.
await expect(getEditDialogDropzone(surface.root)).toBeVisible();
await expect(getEditDialogSelectedFile(surface.root)).not.toBeVisible();
};
// --- DB assertion ---
const assertRenamePersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: {
orderBy: { order: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems.length).toBeGreaterThanOrEqual(1);
expect(envelope.envelopeItems[0].title).toBe('Renamed Document');
};
// --- Tests ---
test.describe('document editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runReplacePdfFlow(surface);
});
test('should clear a selected file before submitting', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runClearFileSelectionFlow(surface);
});
});
test.describe('template editor', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRenameFlow(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});
test.describe('embedded create', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-create',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
test('should replace a PDF from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-edit-dialog-replace',
});
await runReplacePdfFlow(surface);
});
});
test.describe('embedded edit', () => {
test('should rename an envelope item from the fields page', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-edit-dialog-edit',
});
const result = await runRenameFlow(surface);
await clickEnvelopeEditorStep(surface.root, 'upload');
await persistEmbeddedEnvelope(surface);
await assertRenamePersistedInDatabase({ surface, ...result });
});
});
@@ -0,0 +1,388 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import {
type TEnvelopeEditorSurface,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDragHandles,
getEnvelopeItemDropzoneInput,
getEnvelopeItemRemoveButtons,
getEnvelopeItemTitleInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const createPdfPayload = (name: string): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
const getCurrentTitles = async (root: Page) => {
const titleInputs = getEnvelopeItemTitleInputs(root);
const count = await titleInputs.count();
return await Promise.all(
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
);
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const getEnvelopeItemsFromDatabase = async (
surface: TEnvelopeEditorSurface,
externalId: string,
) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return envelope.envelopeItems;
};
type ItemFlowResult = {
externalId: string;
};
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
const input = getEnvelopeItemDropzoneInput(root);
await input.setInputFiles(files);
};
const dragEnvelopeItemByHandle = async ({
root,
sourceIndex,
targetIndex,
}: {
root: Page;
sourceIndex: number;
targetIndex: number;
}) => {
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
await expect(sourceHandle).toBeVisible();
await expect(targetHandle).toBeVisible();
const sourceBox = await sourceHandle.boundingBox();
const targetBox = await targetHandle.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error('Could not resolve drag handle bounding boxes');
}
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetX = targetBox.x + targetBox.width / 2;
const targetY = targetBox.y + targetBox.height / 2;
await root.mouse.move(sourceX, sourceY);
await root.mouse.down();
await root.mouse.move(targetX, targetY, { steps: 20 });
await root.mouse.up();
};
const runEnvelopeItemCrudFlow = async ({
surface,
initialCount,
filesToUpload,
}: {
surface: TEnvelopeEditorSurface;
initialCount: number;
filesToUpload: TestFilePayload[];
}): Promise<ItemFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-items-${nanoid()}`;
await updateExternalId(surface, externalId);
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
// Upload files.
await uploadFiles(root, filesToUpload);
const expectedCountAfterUpload = initialCount + filesToUpload.length;
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterUpload = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterUpload).toHaveLength(expectedCountAfterUpload);
}
// Rename items.
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterRename = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterRename[0].title).toBe('Envelope Item A');
expect(itemsAfterRename[1].title).toBe('Envelope Item B');
}
// Reorder items.
await dragEnvelopeItemByHandle({
root,
sourceIndex: 0,
targetIndex: 1,
});
await expect
.poll(async () => await getCurrentTitles(root))
.toEqual(['Envelope Item B', 'Envelope Item A']);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterReorder = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterReorder[0].title).toBe('Envelope Item B');
expect(itemsAfterReorder[1].title).toBe('Envelope Item A');
}
// Remove first item.
await getEnvelopeItemRemoveButtons(root).first().click();
if (!isEmbedded) {
await root.getByRole('button', { name: 'Delete' }).click();
}
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
if (!isEmbedded) {
await navigateToAddFieldsAndBack(root);
const itemsAfterRemove = await getEnvelopeItemsFromDatabase(surface, externalId);
expect(itemsAfterRemove).toHaveLength(expectedCountAfterUpload - 1);
}
return {
externalId,
};
};
test.describe('document editor', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('document-item-added.pdf')],
});
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('template editor', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('template-item-added.pdf')],
});
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('embedded create', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-items',
});
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 0,
filesToUpload: [
createPdfPayload('embedded-document-item-a.pdf'),
createPdfPayload('embedded-document-item-b.pdf'),
],
});
await persistEmbeddedEnvelope(surface);
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(1); // Expect order 1 because this is a one shot create via embedding. There are no incremental updates.
});
});
test.describe('embedded edit', () => {
test('add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-items',
});
const result = await runEnvelopeItemCrudFlow({
surface,
initialCount: 1,
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
});
await persistEmbeddedEnvelope(surface);
const items = await getEnvelopeItemsFromDatabase(surface, result.externalId);
expect(items).toHaveLength(1);
expect(items[0].title).toBe('Envelope Item A');
expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items.
});
});
test.describe('pending envelope title editing', () => {
test('edit envelope and item titles on a pending envelope', async ({ page }) => {
const recipientEmail = `recipient-${nanoid()}@test.documenso.com`;
const { user, team } = await seedUser();
const pendingDocument = await seedPendingDocument(user, team.id, [recipientEmail], {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`,
});
// Verify the envelope editor loaded with the upload step visible.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Edit the envelope title in the header.
const envelopeTitleInput = page.locator('[data-testid="envelope-title-input"]');
await expect(envelopeTitleInput).toBeEnabled();
await envelopeTitleInput.fill('Updated Pending Title');
// Edit the envelope item title.
const itemTitleInput = getEnvelopeItemTitleInputs(page).first();
await expect(itemTitleInput).toBeEnabled();
await itemTitleInput.fill('Updated Item Title');
// Wait for debounced auto-save to persist by navigating away and back.
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(page, 'upload');
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Verify the titles persisted in the database.
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pendingDocument.id },
include: {
envelopeItems: true,
auditLogs: {
orderBy: { createdAt: 'desc' },
},
},
});
expect(updatedEnvelope.title).toBe('Updated Pending Title');
expect(updatedEnvelope.envelopeItems[0].title).toBe('Updated Item Title');
// Verify audit logs were created for both title changes.
const titleAuditLog = updatedEnvelope.auditLogs.find(
(log) => log.type === 'DOCUMENT_TITLE_UPDATED',
);
expect(titleAuditLog).toBeDefined();
const itemAuditLog = updatedEnvelope.auditLogs.find(
(log) => log.type === 'ENVELOPE_ITEM_UPDATED',
);
expect(itemAuditLog).toBeDefined();
// Verify the upload dropzone is still disabled for pending envelopes.
const dropzoneMessage = page.getByText('Cannot upload items after the document has been sent');
await expect(dropzoneMessage).toBeVisible();
// Drag handles are still rendered but dragging is disabled via isDragDisabled
// when canItemsBeModified is false. Verify at least that the handle is present
// but we cannot programmatically assert isDragDisabled from the DOM.
await expect(getEnvelopeItemDragHandles(page)).toHaveCount(1);
});
});
@@ -0,0 +1,277 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
assertRecipientRole,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientNameInputs,
getRecipientRemoveButtons,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
setRecipientRole,
setSigningOrderValue,
toggleAllowDictateSigners,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type RecipientFlowResult = {
externalId: string;
expectedRecipientsBySigningOrder: Array<{
email: string;
name: string;
role: RecipientRole;
signingOrder: number;
}>;
removedRecipientEmail: string;
};
const TEST_RECIPIENT_VALUES = {
secondRecipient: {
email: 'recipient-two@example.com',
name: 'Recipient Two',
},
thirdRecipient: {
email: 'recipient-three@example.com',
name: 'Recipient Three',
},
embeddedPrimaryRecipient: {
email: 'embedded-primary@example.com',
name: 'Embedded Primary',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
const externalId = `e2e-recipients-${nanoid()}`;
await updateExternalId(surface, externalId);
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
await setRecipientName(surface.root, 0, primaryRecipient.name);
} else {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
primaryRecipient = {
email: surface.userEmail,
name: surface.userName,
};
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
}
await clickAddSignerButton(surface.root);
await clickAddSignerButton(surface.root);
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.email,
);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.name,
);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
await assertRecipientRole(surface.root, 0, 'Needs to approve');
await assertRecipientRole(surface.root, 1, 'Needs to sign');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
'aria-checked',
'true',
);
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
return {
externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [
{
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
role: RecipientRole.APPROVER,
signingOrder: 1,
},
{
email: primaryRecipient.email,
name: primaryRecipient.name,
role: RecipientRole.SIGNER,
signingOrder: 2,
},
],
};
};
const assertRecipientsPersistedInDatabase = async ({
surface,
externalId,
expectedRecipientsBySigningOrder,
removedRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
removedRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
documentMeta: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
const recipient = envelope.recipients[index];
expect(recipient.email).toBe(expectedRecipient.email);
expect(recipient.name).toBe(expectedRecipient.name);
expect(recipient.role).toBe(expectedRecipient.role);
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
false,
);
};
test.describe('document editor', () => {
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('add myself, CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-recipients',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('CRUD, roles, signing order and dictate signers', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-recipients',
});
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,532 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getEnvelopeItemDropzoneInput,
getEnvelopeItemReplaceButtons,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const multiPagePdfBuffer = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer,
});
// --- Shared helpers ---
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const replaceEnvelopeItemPdf = async (
root: Page,
index: number,
file: TestFilePayload,
options?: { isEmbedded?: boolean },
) => {
const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index);
await expect(replaceButton).toBeVisible();
// Listen for the file chooser event before clicking so the native dialog
// is intercepted and never actually shown to the user.
const [fileChooser] = await Promise.all([
root.waitForEvent('filechooser'),
replaceButton.click(),
]);
await fileChooser.setFiles(file);
// The button stays in the DOM but becomes disabled while the replace
// mutation is in flight, then re-enables once the mutation completes.
// Wait for both transitions to guarantee the replace has fully finished.
//
// For embedded surfaces the replacement is purely local state with no
// network round-trip, so it can complete before Playwright observes the
// disabled state. Skip the disabled assertion for embedded surfaces.
if (!options?.isEmbedded) {
await expect(replaceButton).toBeDisabled({ timeout: 15000 });
}
await expect(replaceButton).toBeEnabled({ timeout: 15000 });
};
const assertPdfPageCount = async (root: Page, expectedCount: number) => {
await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute(
'data-page-count',
String(expectedCount),
{ timeout: 15000 },
);
};
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const scrollToPage = async (root: Page, pageNumber: number) => {
const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`);
await pageImage.scrollIntoViewIfNeeded();
await expect(pageImage).toBeVisible({ timeout: 10000 });
// Wait for the Konva stage to initialize after virtualized rendering.
await root.waitForTimeout(1000);
};
const placeFieldOnPage = async (
root: Page,
pageNumber: number,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
if (pageNumber > 1) {
await scrollToPage(root, pageNumber);
}
// Find the canvas corresponding to this page number via Konva stages.
// Since the virtualized list may have multiple canvases, we target the one
// that belongs to the correct page by using the evaluate approach.
const canvas = root.locator('.konva-container canvas');
if (pageNumber === 1) {
await expect(canvas.first()).toBeVisible();
await canvas.first().click({ position });
} else {
// For multi-page, find the canvas at the page position.
// The canvases are rendered in page order within the viewport.
// After scrolling to page N, it should be visible. We use nth based on
// the page index among currently rendered canvases.
// A more reliable approach: click on the page's img and offset into canvas.
const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`);
const pageBox = await pageImg.boundingBox();
if (!pageBox) {
throw new Error(`Could not find bounding box for page ${pageNumber}`);
}
// Click at the desired position relative to the page image.
// The Konva canvas overlays the image, so clicking at the same coordinates works.
await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y);
}
};
// --- Test 1: Basic replace flow ---
type BasicReplaceFlowResult = {
externalId: string;
originalDocumentDataId: string | null;
};
const runBasicReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<BasicReplaceFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-${nanoid()}`;
// For embedded create, upload a PDF first.
if (isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'replace-test-original.pdf');
}
await updateExternalId(surface, externalId);
// Record the original documentDataId so we can assert it changed after replace.
// For embedded flows the externalId only lives in client state until persist,
// so query by envelope ID when available, and skip entirely for embedded create.
let originalDocumentDataId: string | null = null;
if (!isEmbedded) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
} else if (surface.envelopeId) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: surface.envelopeId,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
});
originalDocumentDataId = envelope.envelopeItems[0].documentDataId;
}
// Navigate to addFields step to verify initial page count.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Replace the PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded });
// Navigate to addFields step to verify the PDF loaded correctly.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 1);
// Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
return { externalId, originalDocumentDataId };
};
const assertBasicReplaceInDatabase = async ({
surface,
externalId,
originalDocumentDataId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
originalDocumentDataId: string | null;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.envelopeItems).toHaveLength(1);
const item = envelope.envelopeItems[0];
// The documentDataId should have changed after replace.
if (originalDocumentDataId) {
expect(item.documentDataId).not.toBe(originalDocumentDataId);
}
// Verify documentDataId is a valid non-empty string.
expect(item.documentDataId).toBeTruthy();
expect(typeof item.documentDataId).toBe('string');
// Title and order should be unchanged.
expect(item.order).toBeGreaterThanOrEqual(1);
};
// --- Test 2: Field cleanup replace flow ---
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-replace-recipient@documenso.com',
name: 'Embedded Replace Recipient',
},
};
type FieldCleanupFlowResult = {
externalId: string;
recipientEmail: string;
};
const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise<string> => {
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await clickAddMyselfButton(surface.root);
return surface.userEmail;
};
const uploadMultiPagePdf = async (root: Page) => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: 'multi-page.pdf',
mimeType: 'application/pdf',
buffer: multiPagePdfBuffer,
});
};
const runFieldCleanupReplaceFlow = async (
surface: TEnvelopeEditorSurface,
): Promise<FieldCleanupFlowResult> => {
const { root, isEmbedded } = surface;
const externalId = `e2e-replace-fields-${nanoid()}`;
// Step 1: Get a 3-page PDF loaded.
if (isEmbedded && !surface.envelopeId) {
// Embedded create: upload the multi-page PDF directly.
await uploadMultiPagePdf(root);
} else {
// All other surfaces: replace the existing 1-page PDF with the 3-page one.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), {
isEmbedded,
});
}
await updateExternalId(surface, externalId);
// Step 2: Add a recipient.
const recipientEmail = await setupRecipient(surface);
// Step 3: Navigate to addFields step and verify 3-page PDF is loaded.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
await assertPdfPageCount(root, 3);
// Step 4: Place a Signature field on page 1.
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// Step 5: Scroll to page 2 and place a Text field.
await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 });
const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group');
expect(fieldCountPage2).toBe(1);
// Verify file selector shows "2 Fields".
await expect(root.getByText('2 Fields')).toBeVisible();
// Step 6: Navigate back to upload step.
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Step 7: Replace with 1-page PDF.
await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded });
// Step 8: Navigate to addFields step to verify.
await clickEnvelopeEditorStep(root, 'addFields');
await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 });
// PDF should now be 1 page.
await assertPdfPageCount(root, 1);
// Page 1 field should survive.
fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group');
expect(fieldCountPage1).toBe(1);
// File selector should show "1 Field".
await expect(root.getByText('1 Field')).toBeVisible();
return { externalId, recipientEmail };
};
const assertFieldCleanupInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
fields: true,
recipients: true,
envelopeItems: { orderBy: { order: 'asc' } },
},
orderBy: { createdAt: 'desc' },
});
const recipient = envelope.recipients.find((r) => r.email === recipientEmail);
expect(recipient).toBeDefined();
// Only the page-1 field should remain.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].page).toBe(1);
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
expect(envelope.fields[0].recipientId).toBe(recipient?.id);
// The envelope item should have a 1-page PDF (documentDataId should exist).
expect(envelope.envelopeItems).toHaveLength(1);
expect(envelope.envelopeItems[0].documentDataId).toBeTruthy();
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('template editor', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runBasicReplaceFlow(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runFieldCleanupReplaceFlow(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded create', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
test.describe('embedded edit', () => {
test('replace PDF on an envelope item', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace',
});
const result = await runBasicReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertBasicReplaceInDatabase({
surface,
...result,
});
});
test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-replace-fields',
});
const result = await runFieldCleanupReplaceFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldCleanupInDatabase({
surface,
...result,
});
});
});
@@ -0,0 +1,495 @@
import { type Page, expect, test } from '@playwright/test';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
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 { seedUser } from '@documenso/prisma/seed/users';
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 { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a V2 document envelope via the API with a single SIGNER recipient
* and a SIGNATURE field, suitable for testing save-as-template with data.
*
* Returns the envelope ID, user, team, and recipient email.
*/
const createDocumentWithRecipientAndField = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `e2e-save-as-template-${Date.now()}`,
expiresIn: null,
});
const recipientEmail = `save-template-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Save as Template ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Template Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('save document as template from editor sidebar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
// Navigate back to the upload step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The save as template dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Both checkboxes should be checked by default.
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the new template envelope was created in the database with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
expect(templateEnvelopes.length).toBeGreaterThanOrEqual(1);
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix, not document_.
// This confirms incrementTemplateId was called, not incrementDocumentId.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(surface.userEmail);
// Verify fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
expect(createdTemplate!.recipients[0].fields[0].type).toBe(FieldType.SIGNATURE);
});
test('save document as template without recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Recipients" - "Include Fields" should auto-disable.
await page.locator('#envelopeIncludeRecipients').click();
await expect(page.locator('#envelopeIncludeRecipients')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeDisabled();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created without recipients.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: true,
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify no recipients were included.
expect(createdTemplate!.recipients.length).toBe(0);
});
test('save document as template without fields but with recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Fields" but keep "Include Recipients".
await page.locator('#envelopeIncludeFields').click();
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with recipients but no fields.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(recipientEmail);
// Verify no fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(0);
});
});
test.describe('documents table', () => {
test('save as template from document row dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Wait for the documents table to load.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the actions dropdown for the document row.
await page.getByTestId('document-table-action-btn').first().click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients and fields were included (defaults are both checked).
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
});
});
test.describe('document index page', () => {
test('save as template from document page dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}`,
});
// Wait for the document page to load.
await expect(page.getByRole('heading', { name: /E2E Save as Template/ })).toBeVisible();
// Click the more actions dropdown trigger.
await page.getByTestId('document-page-view-action-btn').click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
});
});
test.describe('legacy ID correctness', () => {
test('save as template uses template counter, not document counter', async ({ page }) => {
// Record the current counter values before the operation.
const [documentCounterBefore, templateCounterBefore] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
await expectToastTextToBeVisible(page, 'Template Created');
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Record the counter values after the operation.
const [documentCounterAfter, templateCounterAfter] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
// The template counter MUST have incremented (at least once - could be more due to
// the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates).
expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value);
// Verify the created template's secondaryId matches the template counter.
const createdTemplate = await prisma.envelope.findFirst({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
orderBy: { createdAt: 'desc' },
});
expect(createdTemplate).not.toBeNull();
expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`);
expect(createdTemplate!.secondaryId).not.toMatch(/^document_/);
});
});
@@ -0,0 +1,506 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type SettingsFlowData = {
externalId: string;
isEmbedded: boolean;
};
const TEST_SETTINGS_VALUES = {
replyTo: 'e2e-settings@example.com',
redirectUrl: 'https://example.com/e2e-settings-complete',
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
expirationMode: 'Custom duration',
expirationAmount: 5,
expirationUnit: 'Weeks',
reminderMode: 'Enabled',
reminderSendAfterAmount: 3,
reminderSendAfterUnit: 'Days',
reminderRepeatMode: 'Custom interval',
reminderRepeatAmount: 7,
reminderRepeatUnit: 'Days',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
};
const DB_EXPECTED_VALUES = {
language: 'fr',
dateFormat: 'dd/MM/yyyy',
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
reminderSettings: {
sendAfter: { unit: 'day', amount: 3 },
repeatEvery: { unit: 'day', amount: 7 },
},
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
emailSettings: {
recipientSigned: false,
recipientSigningRequest: false,
recipientRemoved: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
ownerRecipientExpired: false,
ownerDocumentCreated: false,
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const clickSettingsDialogHeader = async (root: Page) => {
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
};
const getComboboxByLabel = (root: Page, label: string) =>
root
.locator(`label:has-text("${label}")`)
.locator('xpath=..')
.locator('[role="combobox"]')
.first();
const selectMultiSelectOption = async (
root: Page,
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
optionLabel: string,
) => {
const select = root.locator(`[data-testid="${dataTestId}"]`);
await select.click();
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
await clickSettingsDialogHeader(root);
};
const runSettingsFlow = async (
{ root }: TEnvelopeEditorSurface,
{ externalId, isEmbedded }: SettingsFlowData,
) => {
await openSettingsDialog(root);
await getComboboxByLabel(root, 'Language').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
await clickSettingsDialogHeader(root);
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
await signatureTypesCombobox.click();
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Time Zone').click();
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
await clickSettingsDialogHeader(root);
await root.locator('input[name="externalId"]').fill(externalId);
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Expiration').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationMode }).click();
await root.getByRole('spinbutton').clear();
await root.getByRole('spinbutton').fill(String(TEST_SETTINGS_VALUES.expirationAmount));
const expirationUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ })
.first();
await expirationUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
await clickSettingsDialogHeader(root);
// Configure reminder settings.
await root.getByRole('button', { name: 'Reminders' }).click();
await root.locator('[data-testid="reminder-mode-select"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-send-after-amount"]').clear();
await root
.locator('[data-testid="reminder-send-after-amount"]')
.fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
await root.locator('[data-testid="reminder-send-after-unit"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
await clickSettingsDialogHeader(root);
await root.locator('[data-testid="reminder-repeat-amount"]').clear();
await root
.locator('[data-testid="reminder-repeat-amount"]')
.fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
await root.locator('[data-testid="reminder-repeat-unit"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
await clickSettingsDialogHeader(root);
const spinbuttons = root.getByRole('spinbutton');
await spinbuttons.first().clear();
await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
const sendAfterUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Days|Weeks|Months/ })
.first();
await sendAfterUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
await clickSettingsDialogHeader(root);
const repeatModeSelect = root
.locator('button[role="combobox"]')
.filter({ hasText: /Custom interval|Don't repeat/ })
.first();
await repeatModeSelect.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
await clickSettingsDialogHeader(root);
await spinbuttons.last().clear();
await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
const repeatUnitTrigger = root
.locator('button[role="combobox"]')
.filter({ hasText: /Days|Weeks|Months/ })
.last();
await repeatUnitTrigger.click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
await root.locator('#recipientRemoved').click();
await root.locator('#documentPending').click();
await root.locator('#documentCompleted').click();
await root.locator('#documentDeleted').click();
await root.locator('#ownerDocumentCompleted').click();
await root.locator('#ownerRecipientExpired').click();
await root.locator('#ownerDocumentCreated').click();
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
await root.getByRole('button', { name: 'Security' }).click();
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
if (hasActionAuthSelect) {
await selectMultiSelectOption(
root,
'documentActionSelectValue',
TEST_SETTINGS_VALUES.actionAuth,
);
}
if (isEmbedded) {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
} else {
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
await clickSettingsDialogHeader(root);
}
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
await openSettingsDialog(root);
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
TEST_SETTINGS_VALUES.redirectUrl,
);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
TEST_SETTINGS_VALUES.dateFormat,
);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.distributionMethod,
);
await expect(getComboboxByLabel(root, 'Expiration')).toContainText(
TEST_SETTINGS_VALUES.expirationMode,
);
await expect(root.getByRole('spinbutton')).toHaveValue(
String(TEST_SETTINGS_VALUES.expirationAmount),
);
await expect(
root
.locator('button[role="combobox"]')
.filter({ hasText: TEST_SETTINGS_VALUES.expirationUnit })
.first(),
).toBeVisible();
// Verify reminder settings persisted in UI.
await root.getByRole('button', { name: 'Reminders' }).click();
await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(
TEST_SETTINGS_VALUES.reminderMode,
);
await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
);
await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
TEST_SETTINGS_VALUES.reminderSendAfterUnit,
);
await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
TEST_SETTINGS_VALUES.reminderRepeatMode,
);
await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
);
await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
TEST_SETTINGS_VALUES.reminderRepeatUnit,
);
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
TEST_SETTINGS_VALUES.replyTo,
);
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
TEST_SETTINGS_VALUES.subject,
);
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
TEST_SETTINGS_VALUES.message,
);
await root.getByRole('button', { name: 'Security' }).click();
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.accessAuth,
);
if (hasActionAuthSelect) {
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.actionAuth,
);
}
if (isEmbedded) {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toHaveCount(0);
} else {
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.visibility,
);
}
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
return {
hasActionAuthSelect,
};
};
const assertEnvelopeSettingsPersistedInDatabase = async ({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
hasActionAuthSelect: boolean;
shouldAssertVisibility: boolean;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: {
documentMeta: true,
},
});
expect(envelope.externalId).toBe(externalId);
if (shouldAssertVisibility) {
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
}
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
);
expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
if (hasActionAuthSelect) {
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
}
};
const parseAuthOptions = (
authOptions: unknown,
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
if (!isRecord(authOptions)) {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
return {
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
test.describe('document editor', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: true,
});
});
});
test.describe('template editor', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: true,
});
});
});
test.describe('embedded create', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: false,
});
});
});
test.describe('embedded edit', () => {
test('update and persist settings', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
shouldAssertVisibility: false,
});
});
});
@@ -1,7 +1,7 @@
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 { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
@@ -161,7 +161,16 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
inserted: false,
OR: [
{
inserted: false,
},
{
// Include email fields because they are automatically inserted during envelope distribution.
// We need to extract it to override their values for accurate comparison in tests.
type: FieldType.EMAIL,
},
],
},
include: {
envelopeItem: {
@@ -25,7 +25,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByRole('spinbutton');
const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('2');
@@ -33,9 +33,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
// In the duration mode, there's a mode select and a unit select.
// The unit select is inside the duration row, after the number input.
// Let's find the select trigger that contains the unit text.
const unitTrigger = page
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ });
const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
@@ -65,9 +63,7 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page
.locator('button[role="combobox"]')
.filter({ hasText: 'Custom duration' });
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
@@ -118,11 +114,8 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Scope to the "Default Envelope Expiration" form field section.
const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
// The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
// Switch to custom duration.
@@ -130,13 +123,11 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days.
const amountInput = expirationSection.getByRole('spinbutton');
const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('5');
const unitTrigger = expirationSection
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ });
const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
@@ -0,0 +1,264 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { prisma } from '@documenso/prisma';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
test.describe('V2 envelope field insertion during signing', () => {
test('date fields are auto-inserted when completing a V2 envelope', async ({ page, request }) => {
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [{ email: 'signer-date@test.documenso.com', name: 'Date Signer' }],
fieldsPerRecipient: [
[
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
],
],
});
const { token } = distributeResult.recipients[0];
await page.goto(`/sign/${token}`);
// wait for PDF to be visible
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Wait for the Konva canvas to be ready.
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// DATE is auto-filled, but SIGNATURE still needs manual interaction.
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
// Set up a signature via the sidebar form.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
// Click the signature field on the canvas.
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const sigField = envelope.fields.find((f) => f.type === FieldType.SIGNATURE);
if (!sigField) {
throw new Error('Signature field not found');
}
const x =
(Number(sigField.positionX) / 100) * canvasBox.width +
((Number(sigField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(sigField.positionY) / 100) * canvasBox.height +
((Number(sigField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await page.waitForTimeout(500);
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Verify the date field was inserted in the database with the correct format.
const dateField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.DATE,
},
});
expect(dateField.inserted).toBe(true);
expect(dateField.customText).toBeTruthy();
// Verify the inserted date is close to now (within 2 minutes).
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
zone: DEFAULT_DOCUMENT_TIME_ZONE,
});
expect(insertedDate.isValid).toBe(true);
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
// Verify the document reached COMPLETED status.
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('date and email fields are inserted when completing a V2 envelope with multiple field types', async ({
page,
request,
}) => {
const now = DateTime.now().setZone(DEFAULT_DOCUMENT_TIME_ZONE);
const recipientEmail = 'signer-multi@test.documenso.com';
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [{ email: recipientEmail, name: 'Multi Signer' }],
fieldsPerRecipient: [
[
{ type: FieldType.DATE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.EMAIL, page: 1, positionX: 5, positionY: 10, width: 5, height: 5 },
{ type: FieldType.NAME, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 20,
width: 5,
height: 5,
},
],
],
});
const { token } = distributeResult.recipients[0];
// Resolve the fields from the envelope for position calculations.
const fields = envelope.fields;
await page.goto(`/sign/${token}`);
// wait for PDF to be visible
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Wait for the Konva canvas to be ready.
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// DATE and EMAIL fields are auto-filled, so only NAME and SIGNATURE remain.
await expect(page.getByText('2 Fields Remaining').first()).toBeVisible();
// Set up a signature via the sidebar form.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
// Click each non-date field on the Konva canvas to insert it.
// Fields are seeded with positions as percentages of the page.
// We need to convert these percentages to pixel positions on the canvas.
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
// Only NAME and SIGNATURE fields need manual interaction (DATE and EMAIL are auto-filled).
const manualFields = fields.filter(
(f) => f.type !== FieldType.DATE && f.type !== FieldType.EMAIL,
);
for (const field of manualFields) {
const x =
(Number(field.positionX) / 100) * canvasBox.width +
((Number(field.width) / 100) * canvasBox.width) / 2;
const y =
(Number(field.positionY) / 100) * canvasBox.height +
((Number(field.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
if (field.type === FieldType.NAME) {
const nameDialog = page.getByRole('dialog');
const isDialogVisible = await nameDialog.isVisible().catch(() => false);
if (isDialogVisible) {
const nameInput = nameDialog.locator('input[type="text"], input[name="name"]').first();
const isInputVisible = await nameInput.isVisible().catch(() => false);
if (isInputVisible) {
await nameInput.fill('Test Signer');
}
const saveButton = nameDialog.getByRole('button', { name: 'Save' });
const isButtonVisible = await saveButton.isVisible().catch(() => false);
if (isButtonVisible) {
await saveButton.click();
}
}
}
// Small delay to allow the field signing to complete.
await page.waitForTimeout(500);
}
// All fields should now be complete.
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Verify the date field was auto-inserted with the correct format.
const dateField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.DATE,
},
});
expect(dateField.inserted).toBe(true);
expect(dateField.customText).toBeTruthy();
const insertedDate = DateTime.fromFormat(dateField.customText, DEFAULT_DOCUMENT_DATE_FORMAT, {
zone: DEFAULT_DOCUMENT_TIME_ZONE,
});
expect(insertedDate.isValid).toBe(true);
expect(Math.abs(insertedDate.diff(now, 'minutes').minutes)).toBeLessThanOrEqual(2);
// Verify the email field was inserted with the recipient's email.
const emailField = await prisma.field.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
expect(emailField.inserted).toBe(true);
expect(emailField.customText).toBe(recipientEmail);
// Verify all fields are inserted.
const allFields = await prisma.field.findMany({
where: { envelopeId: envelope.id },
});
for (const field of allFields) {
expect(field.inserted).toBe(true);
}
// Verify the document reached COMPLETED status.
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
});
@@ -0,0 +1,901 @@
/**
* API V2-based seed fixtures for E2E tests.
*
* These fixtures create documents, templates, envelopes, recipients, fields,
* and folders through the API V2 endpoints instead of direct Prisma calls.
* This ensures all creation-time side effects (PDF normalization, field meta
* defaults, etc.) are exercised the same way a real user would trigger them.
*
* Usage:
* import { apiSeedDraftDocument, apiSeedPendingDocument, ... } from '../fixtures/api-seeds';
*
* test('my test', async ({ request }) => {
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@example.com', name: 'Signer', role: 'SIGNER' }],
* });
* });
*/
import { type APIRequestContext, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type {
TDistributeEnvelopeRequest,
TDistributeEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsResponse } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ApiRecipient = {
email: string;
name?: string;
role?: 'SIGNER' | 'APPROVER' | 'VIEWER' | 'CC' | 'ASSISTANT';
signingOrder?: number;
accessAuth?: string[];
actionAuth?: string[];
};
export type ApiField = {
recipientId: number;
envelopeItemId?: string;
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
placeholder?: string;
matchAll?: boolean;
};
export type ApiSeedContext = {
user: Awaited<ReturnType<typeof seedUser>>['user'];
team: Awaited<ReturnType<typeof seedUser>>['team'];
token: string;
};
export type ApiSeedEnvelopeOptions = {
title?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
externalId?: string;
visibility?: string;
globalAccessAuth?: string[];
globalActionAuth?: string[];
folderId?: string;
pdfFile?: { name: string; data: Buffer };
meta?: TCreateEnvelopePayload['meta'];
recipients?: Array<
ApiRecipient & {
fields?: Array<{
type: string;
identifier?: string | number;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>;
}
>;
};
// ---------------------------------------------------------------------------
// Core API helpers (low-level)
// ---------------------------------------------------------------------------
/**
* Create a fresh user + team + API token for test isolation.
* Every high-level seed function calls this internally, but you can also
* call it directly if you need the context for multiple operations.
*/
export const apiCreateTestContext = async (tokenName = 'e2e-seed'): Promise<ApiSeedContext> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName,
expiresIn: null,
});
return { user, team, token };
};
const authHeader = (token: string) => ({
Authorization: `Bearer ${token}`,
});
/**
* Create an envelope via API V2 with a PDF file attached.
*
* This is the lowest-level envelope creation function. It creates the
* envelope with optional inline recipients and fields in a single call.
*/
export const apiCreateEnvelope = async (
request: APIRequestContext,
token: string,
options: ApiSeedEnvelopeOptions = {},
): Promise<TCreateEnvelopeResponse> => {
const {
title = '[TEST] API Seeded Envelope',
type = 'DOCUMENT',
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
folderId,
pdfFile,
meta,
recipients,
} = options;
// Build payload as a plain object. The API receives this as a JSON string
// inside multipart form data, so strict TypeScript union narrowing is not
// required - the server validates with Zod at runtime.
const payload: Record<string, unknown> = {
title,
type,
};
if (externalId !== undefined) {
payload.externalId = externalId;
}
if (visibility !== undefined) {
payload.visibility = visibility;
}
if (globalAccessAuth !== undefined) {
payload.globalAccessAuth = globalAccessAuth;
}
if (globalActionAuth !== undefined) {
payload.globalActionAuth = globalActionAuth;
}
if (folderId !== undefined) {
payload.folderId = folderId;
}
if (meta !== undefined) {
payload.meta = meta;
}
if (recipients !== undefined) {
payload.recipients = recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
if (r.fields !== undefined) {
recipientPayload.fields = r.fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
type: f.type,
page: f.page ?? 1,
positionX: f.positionX ?? 10,
positionY: f.positionY ?? 10,
width: f.width ?? 15,
height: f.height ?? 5,
};
if (f.identifier !== undefined) {
fieldPayload.identifier = f.identifier;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
return fieldPayload;
});
}
return recipientPayload;
});
}
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdf = pdfFile ?? { name: 'example.pdf', data: examplePdfBuffer };
formData.append('files', new File([pdf.data], pdf.name, { type: 'application/pdf' }));
const res = await request.post(`${API_BASE_URL}/envelope/create`, {
headers: authHeader(token),
multipart: formData,
});
expect(res.ok(), `envelope/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
/**
* Get full envelope data via API V2.
*/
export const apiGetEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
headers: authHeader(token),
});
expect(res.ok(), `envelope/get failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Add recipients to an existing envelope via API V2.
*/
export const apiCreateRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: ApiRecipient[],
): Promise<TCreateEnvelopeRecipientsResponse> => {
const data = {
envelopeId,
data: recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
return recipientPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `recipient/create-many failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeRecipientsResponse;
};
/**
* Add fields to an existing envelope via API V2.
*
* If `recipientId` is not set on fields, the first recipient is used.
* If `envelopeItemId` is not set, the first envelope item is used.
*/
export const apiCreateFields = async (
request: APIRequestContext,
token: string,
envelopeId: string,
fields: ApiField[],
): Promise<void> => {
// Build as plain object - the deeply discriminated union types for fields
// (type + fieldMeta combinations) are validated by Zod on the server.
const data = {
envelopeId,
data: fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
recipientId: f.recipientId,
type: f.type,
};
if (f.envelopeItemId !== undefined) {
fieldPayload.envelopeItemId = f.envelopeItemId;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
if (f.placeholder) {
fieldPayload.placeholder = f.placeholder;
if (f.width !== undefined) {
fieldPayload.width = f.width;
}
if (f.height !== undefined) {
fieldPayload.height = f.height;
}
if (f.matchAll !== undefined) {
fieldPayload.matchAll = f.matchAll;
}
} else {
fieldPayload.page = f.page ?? 1;
fieldPayload.positionX = f.positionX ?? 10;
fieldPayload.positionY = f.positionY ?? 10;
fieldPayload.width = f.width ?? 15;
fieldPayload.height = f.height ?? 5;
}
return fieldPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/field/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `field/create-many failed: ${await res.text()}`).toBeTruthy();
};
/**
* Distribute (send) an envelope via API V2.
* Returns the distribute response which includes signing URLs for recipients.
*/
export const apiDistributeEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
meta?: TDistributeEnvelopeRequest['meta'],
): Promise<TDistributeEnvelopeResponse> => {
const data: TDistributeEnvelopeRequest = {
envelopeId,
...(meta !== undefined && { meta }),
};
const res = await request.post(`${API_BASE_URL}/envelope/distribute`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `envelope/distribute failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TDistributeEnvelopeResponse;
};
/**
* Create a folder via API V2.
*/
export const apiCreateFolder = async (
request: APIRequestContext,
token: string,
options: {
name?: string;
parentId?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
} = {},
): Promise<{ id: string; name: string }> => {
const { name = 'Test Folder', parentId, type } = options;
const res = await request.post(`${API_BASE_URL}/folder/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
name,
...(parentId !== undefined && { parentId }),
...(type !== undefined && { type }),
},
});
expect(res.ok(), `folder/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as { id: string; name: string };
};
/**
* Create a direct template link via API V2.
*/
export const apiCreateDirectTemplateLink = async (
request: APIRequestContext,
token: string,
templateId: number,
directRecipientId?: number,
): Promise<{ id: number; token: string; enabled: boolean; directTemplateRecipientId: number }> => {
const res = await request.post(`${API_BASE_URL}/template/direct/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
templateId,
...(directRecipientId !== undefined && { directRecipientId }),
},
});
expect(res.ok(), `template/direct/create failed: ${await res.text()}`).toBeTruthy();
return await res.json();
};
// ---------------------------------------------------------------------------
// High-level seed functions (composites)
// ---------------------------------------------------------------------------
export type ApiSeedResult = {
/** The created envelope/document/template. */
envelope: TGetEnvelopeResponse;
/** API token for further API calls. */
token: string;
/** The seeded user. */
user: ApiSeedContext['user'];
/** The seeded team. */
team: ApiSeedContext['team'];
};
export type ApiSeedDocumentOptions = {
/** Document title. Default: '[TEST] API Document - Draft' */
title?: string;
/** Recipients to add to the document. */
recipients?: ApiRecipient[];
/** Fields to add per recipient. If provided, must match recipients order. */
fieldsPerRecipient?: Array<
Array<{
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>
>;
/** External ID for the envelope. */
externalId?: string;
/** Document visibility setting. */
visibility?: string;
/** Global access auth requirements. */
globalAccessAuth?: string[];
/** Global action auth requirements. */
globalActionAuth?: string[];
/** Folder ID to place the document in. */
folderId?: string;
/** Document meta settings. */
meta?: TCreateEnvelopePayload['meta'];
/** Custom PDF file. Default: example.pdf */
pdfFile?: { name: string; data: Buffer };
/** Reuse an existing test context instead of creating a new one. */
context?: ApiSeedContext;
};
/**
* Create a draft document via API V2.
*
* Creates a user, team, API token, and a DRAFT document. Optionally adds
* recipients and fields.
*
* @example
* ```ts
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@test.com', name: 'Test Signer' }],
* });
* ```
*/
export const apiSeedDraftDocument = async (
request: APIRequestContext,
options: ApiSeedDocumentOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-draft-doc'));
// Create the envelope with inline recipients if provided
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Document - Draft',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
// If we have recipients but no per-recipient fields, use inline creation
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
// If we need per-recipient fields, add recipients and fields separately
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(
request,
ctx.token,
envelopeId,
options.recipients,
);
// Get envelope to resolve envelope item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Create fields for each recipient
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
const recipientId = recipientsRes.data[index].id;
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
export type ApiSeedPendingDocumentOptions = ApiSeedDocumentOptions & {
/** Distribution meta (subject, message, etc.). */
distributeMeta?: TDistributeEnvelopeRequest['meta'];
};
/**
* Seed a pending (distributed) document via API V2.
*
* Creates the document, adds recipients with SIGNATURE fields, then
* distributes (sends) it. The response includes signing URLs for each
* recipient.
*
* Every SIGNER recipient must have at least one SIGNATURE field for
* distribution to succeed. If you don't provide `fieldsPerRecipient`,
* a default SIGNATURE field is added for each SIGNER/APPROVER recipient.
*
* @example
* ```ts
* const { envelope, distributeResult, token } = await apiSeedPendingDocument(request, {
* recipients: [
* { email: 'signer@test.com', name: 'Signer' },
* { email: 'viewer@test.com', name: 'Viewer', role: 'VIEWER' },
* ],
* });
*
* // Access signing URL:
* const signingUrl = distributeResult.recipients[0].signingUrl;
* ```
*/
export const apiSeedPendingDocument = async (
request: APIRequestContext,
options: ApiSeedPendingDocumentOptions = {},
): Promise<
ApiSeedResult & {
distributeResult: TDistributeEnvelopeResponse;
}
> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-pending-doc'));
const recipients = options.recipients ?? [
{
email: `signer-${Date.now()}@test.documenso.com`,
name: 'Test Signer',
role: 'SIGNER' as const,
},
];
// Create the base envelope
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, {
title: options.title ?? '[TEST] API Document - Pending',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
});
// Add recipients
const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, recipients);
// Get envelope for item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Add fields
if (options.fieldsPerRecipient) {
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
} else {
// Auto-add a SIGNATURE field for each SIGNER/APPROVER recipient
const signerFields: ApiField[] = [];
for (const [index, r] of recipientsRes.data.entries()) {
const role = recipients[index].role ?? 'SIGNER';
if (role === 'SIGNER' || role === 'APPROVER') {
signerFields.push({
recipientId: r.id,
envelopeItemId: firstItemId,
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 15,
height: 5,
});
}
}
if (signerFields.length > 0) {
await apiCreateFields(request, ctx.token, envelopeId, signerFields);
}
}
// Distribute
const distributeResult = await apiDistributeEnvelope(
request,
ctx.token,
envelopeId,
options.distributeMeta,
);
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return {
envelope,
distributeResult,
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
export type ApiSeedTemplateOptions = Omit<ApiSeedDocumentOptions, 'folderId'> & {
/** Folder ID to place the template in. */
folderId?: string;
};
/**
* Seed a template via API V2.
*
* Creates a TEMPLATE envelope with optional recipients and fields.
*
* @example
* ```ts
* const { envelope, token } = await apiSeedTemplate(request, {
* title: 'My Template',
* recipients: [{ email: 'recipient@test.com', name: 'Signer', role: 'SIGNER' }],
* });
* ```
*/
export const apiSeedTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-template'));
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Template',
type: 'TEMPLATE',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(
request,
ctx.token,
envelopeId,
options.recipients,
);
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
/**
* Seed a template with a direct link via API V2.
*
* Creates a template with a recipient, then creates a direct link for it.
*
* @example
* ```ts
* const { envelope, directLink, token } = await apiSeedDirectTemplate(request, {
* title: 'Direct Template',
* });
*
* // Use directLink.token for the signing URL
* ```
*/
export const apiSeedDirectTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions & {
/** Custom recipient for the direct link. Default: a SIGNER placeholder. */
directRecipient?: ApiRecipient;
} = {},
): Promise<
ApiSeedResult & {
directLink: { id: number; token: string; enabled: boolean; directTemplateRecipientId: number };
}
> => {
const recipients = options.recipients ?? [
options.directRecipient ?? {
email: 'direct-template-recipient@documenso.com',
name: 'Direct Template Recipient',
role: 'SIGNER' as const,
},
];
const templateResult = await apiSeedTemplate(request, {
...options,
recipients,
});
// Find the recipient ID for the direct link
const directRecipientEmail = options.directRecipient?.email ?? recipients[0].email;
const directRecipient = templateResult.envelope.recipients.find(
(r) => r.email === directRecipientEmail,
);
if (!directRecipient) {
throw new Error(`Direct template recipient not found: ${directRecipientEmail}`);
}
const numericTemplateId = mapSecondaryIdToTemplateId(templateResult.envelope.secondaryId);
const directLink = await apiCreateDirectTemplateLink(
request,
templateResult.token,
numericTemplateId,
directRecipient.id,
);
// Re-fetch envelope to include directLink data
const envelope = await apiGetEnvelope(request, templateResult.token, templateResult.envelope.id);
return {
...templateResult,
envelope,
directLink,
};
};
/**
* Seed multiple draft documents in parallel for a single user context.
*
* Useful for tests that need multiple documents (e.g., bulk actions, find/filter tests).
*
* @example
* ```ts
* const { documents, token, user, team } = await apiSeedMultipleDraftDocuments(request, [
* { title: 'Doc A' },
* { title: 'Doc B' },
* { title: 'Doc C' },
* ]);
* ```
*/
export const apiSeedMultipleDraftDocuments = async (
request: APIRequestContext,
documents: ApiSeedDocumentOptions[],
context?: ApiSeedContext,
): Promise<{
documents: TGetEnvelopeResponse[];
token: string;
user: ApiSeedContext['user'];
team: ApiSeedContext['team'];
}> => {
const ctx = context ?? (await apiCreateTestContext('e2e-multi-doc'));
const results = await Promise.all(
documents.map(async (docOptions) =>
apiSeedDraftDocument(request, { ...docOptions, context: ctx }),
),
);
return {
documents: results.map((r) => r.envelope),
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
@@ -0,0 +1,437 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './authentication';
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
export type TEnvelopeEditorSurface = {
root: Page;
isEmbedded: boolean;
envelopeId?: string;
envelopeType: TEnvelopeEditorType;
userId: number;
userEmail: string;
userName: string;
teamId: number;
};
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
type TEmbeddedHashCommonOptions = {
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
const encodedPayload = encodeURIComponent(JSON.stringify(options));
if (typeof btoa === 'function') {
return btoa(encodedPayload);
}
return Buffer.from(encodedPayload, 'utf8').toString('base64');
};
export const createEmbeddedEnvelopeCreateHash = ({
envelopeType,
externalId,
folderId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: { envelopeType: TEnvelopeEditorType; folderId?: string } & TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
type: envelopeType,
folderId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const createEmbeddedEnvelopeEditHash = ({
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: document.id,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: template.id,
envelopeType: 'TEMPLATE',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
type OpenEmbeddedEnvelopeEditorOptions = {
envelopeType: TEnvelopeEditorType;
mode?: 'create' | 'edit';
tokenNamePrefix?: string;
externalId?: string;
folderId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
export const openEmbeddedEnvelopeEditor = async (
page: Page,
{
envelopeType,
mode = 'create',
tokenNamePrefix = 'e2e-embed',
externalId,
folderId,
features,
css,
cssVars,
darkModeDisabled,
}: OpenEmbeddedEnvelopeEditorOptions,
): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const envelopeToEdit =
mode === 'edit'
? envelopeType === 'DOCUMENT'
? await seedBlankDocument(user, team.id, {
internalVersion: 2,
})
: await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
})
: null;
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
expiresIn: null,
});
const embeddedToken = await resolveEmbeddingToken(
page,
token,
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
);
if (envelopeToEdit) {
const hash = createEmbeddedEnvelopeEditHash({
externalId,
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
} else {
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType,
externalId,
folderId,
features,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
}
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeId: envelopeToEdit?.id,
envelopeType,
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
root.locator('button[title="Settings"]');
export const getEnvelopeItemTitleInputs = (root: Page) =>
root.locator('[data-testid^="envelope-item-title-input-"]');
export const getEnvelopeItemDragHandles = (root: Page) =>
root.locator('[data-testid^="envelope-item-drag-handle-"]');
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
export const getEnvelopeItemReplaceButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-replace-button-"]');
export const getEnvelopeItemDropzoneInput = (root: Page) =>
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: fileName,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
};
export const getRecipientEmailInputs = (root: Page) =>
root.locator('[data-testid="signer-email-input"]');
export const getRecipientNameInputs = (root: Page) =>
root.locator('input[placeholder^="Recipient "]');
export const getRecipientRows = (root: Page) =>
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
export const getRecipientRemoveButtons = (root: Page) =>
root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) =>
root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (
root: Page,
stepId: 'upload' | 'addFields' | 'preview',
) => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
};
export const clickAddMyselfButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Myself' }).click();
};
export const clickAddSignerButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Signer' }).click();
};
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
await getRecipientEmailInputs(root).nth(index).fill(email);
};
export const setRecipientName = async (root: Page, index: number, name: string) => {
await getRecipientNameInputs(root).nth(index).fill(name);
};
export const setRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
await row.locator('button[role="combobox"]').first().click();
await root.getByRole('option', { name: roleLabel }).click();
};
export const assertRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
const roleValueByLabel: Record<typeof roleLabel, string> = {
'Needs to sign': 'SIGNER',
'Needs to approve': 'APPROVER',
'Needs to view': 'VIEWER',
'Receives copy': 'CC',
'Can prepare': 'ASSISTANT',
};
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
'title',
roleValueByLabel[roleLabel],
);
};
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#signingOrder');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#allowDictateNextSigner');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
if (!surface.isEmbedded) {
return;
}
const isUpdateFlow =
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
const actionButtonName = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Update Document'
: 'Update Template'
: surface.envelopeType === 'DOCUMENT'
? 'Create Document'
: 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
const completionHeading = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Document Updated'
: 'Template Updated'
: surface.envelopeType === 'DOCUMENT'
? 'Document Created'
: 'Template Created';
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
};
const resolveEmbeddingToken = async (
page: Page,
inputToken: string,
scope?: string,
): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
data: scope ? { scope } : {},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
}
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
const token = data.token;
if (typeof token !== 'string' || token.length === 0) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return token;
};
+22
View File
@@ -0,0 +1,22 @@
import type { Page } from '@playwright/test';
import type Konva from 'konva';
export const getKonvaElementCountForPage = async (
page: Page,
pageNumber: number,
elementSelector: string,
) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber, elementSelector }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const pageOne = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
return pageOne?.find(elementSelector).length || 0;
},
{ pageNumber, elementSelector },
);
};
@@ -231,6 +231,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentDeleted: false, // unchecked
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
// Edit the team email settings
@@ -271,6 +272,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
ownerDocumentCreated: true,
});
// Verify that a document can be created successfully with the team email settings
@@ -292,6 +294,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
ownerDocumentCreated: true,
});
// Test inheritance by setting team back to inherit from organisation
@@ -318,6 +321,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
// Verify that a document can be created successfully with the email settings
@@ -339,5 +343,6 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
ownerDocumentCreated: true,
});
});
@@ -0,0 +1,425 @@
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prefixedId } from '@documenso/lib/universal/id';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
seedCompletedDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
async function addSecondEnvelopeItem(envelopeId: string) {
const firstItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId },
orderBy: { order: 'asc' },
include: { documentData: true },
});
const newDocumentData = await prisma.documentData.create({
data: {
type: firstItem.documentData.type,
data: firstItem.documentData.data,
initialData: firstItem.documentData.initialData,
},
});
await prisma.envelopeItem.create({
data: {
id: prefixedId('envelope_item'),
title: `${firstItem.title} - Page 2`,
documentDataId: newDocumentData.id,
order: 2,
envelopeId,
},
});
}
test.describe('PDF Viewer Rendering', () => {
test.describe('Authenticated Pages', () => {
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const documentV1 = await seedBlankDocument(user, team.id);
const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await addSecondEnvelopeItem(documentV2.id);
const templateV1 = await seedBlankTemplate(user, team.id);
const templateV2 = await seedBlankTemplate(user, team.id, {
createTemplateOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(templateV2.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${documentV1.id}`,
});
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV2.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV1.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV2.id}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
});
});
test.describe('Recipient Signing', () => {
test('should render PDF on signing page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['signer-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['signer-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/sign/${recipientsV1[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/sign/${recipientsV2[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
test.describe('Direct Template', () => {
test('should render PDF on direct template page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const templateV1 = await seedDirectTemplate({
title: 'PDF Viewer Test Template V1',
userId: user.id,
teamId: team.id,
});
const templateV2 = await seedDirectTemplate({
title: 'PDF Viewer Test Template V2',
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await addSecondEnvelopeItem(templateV2.id);
await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || ''));
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || ''));
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
test.describe('Share Page', () => {
test('should render PDF on share page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(
user,
team.id,
['share-v1@test.documenso.com'],
{
createDocumentOptions: { qrToken: qrTokenV1 },
},
);
const documentV2 = await seedCompletedDocument(
user,
team.id,
['share-v2@test.documenso.com'],
{
createDocumentOptions: { qrToken: qrTokenV2 },
internalVersion: 2,
},
);
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should not return 500 for invalid QR share token', async ({ page }) => {
const response = await page.request.get('/share/qr_invalid_token_for_regression_check', {
maxRedirects: 0,
});
expect(response.status()).toBe(302);
expect(response.headers().location).toBe('/');
});
});
test.describe('Embed Pages', () => {
test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['embed-signer-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['embed-signer-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/embed/sign/${recipientsV1[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/sign/${recipientsV2[0].token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /Page 2/ }).click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const templateV1 = await seedDirectTemplate({
title: 'Embed Direct Template V1',
userId: user.id,
teamId: team.id,
});
const templateV2 = await seedDirectTemplate({
title: 'Embed Direct Template V2',
userId: user.id,
teamId: team.id,
internalVersion: 2,
});
await addSecondEnvelopeItem(templateV2.id);
await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['multisign-v1@test.documenso.com'],
fields: [FieldType.SIGNATURE],
});
const { document: documentV2, recipients: recipientsV2 } =
await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['multisign-v2@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
await addSecondEnvelopeItem(documentV2.id);
await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`);
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /View/i }).first().click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: /View/i }).first().click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Todo: Multisign does not support multiple envelope items.
// await page.getByRole('button', { name: /Page 2/ }).click();
// await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed authoring document create page', async ({ page }) => {
const { user, team } = await seedUser();
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
});
const embedParams = { darkModeDisabled: false, features: {} };
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`,
);
await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 });
const titleInput = page.getByLabel('Title');
await titleInput.click();
await titleInput.fill('PDF Viewer E2E Test');
const emailInput = page.getByPlaceholder('Email').first();
await emailInput.click();
await emailInput.fill('test-signer@documenso.com');
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page
.locator('input[type=file]')
.first()
.evaluate((el) => {
if (el instanceof HTMLInputElement) {
el.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed document edit page', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
const documentId = mapSecondaryIdToDocumentId(document.secondaryId);
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-doc-edit-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
scope: `documentId:${documentId}`,
});
const embedParams = {
darkModeDisabled: false,
features: {},
onlyEditFields: true,
};
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`,
);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should render PDF on embed template edit page', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
const templateId = mapSecondaryIdToTemplateId(template.secondaryId);
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'pdf-viewer-template-edit-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({
apiToken,
scope: `templateId:${templateId}`,
});
const embedParams = {
darkModeDisabled: false,
features: {},
onlyEditFields: true,
};
const hash = btoa(encodeURIComponent(JSON.stringify(embedParams)));
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`,
);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
});
});
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);
@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 500,
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 100,
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
position: {
x: 100,
y: 200,
@@ -136,7 +136,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
@@ -163,7 +163,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
@@ -187,7 +187,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
@@ -247,7 +247,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('combobox').nth(7).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
@@ -117,7 +117,13 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByText('Template Duplicated').first()).toBeVisible();
// The dialog should navigate to the new template's edit page.
await page.waitForURL(/\/templates\/.*\/edit/);
// Navigate back to the templates list and verify the count is now 2.
await page.goto(`/t/${team.url}/templates`);
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
@@ -0,0 +1,553 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
/**
* Helper to set up the standard two-team-one-org scenario:
*
* - One organisation with two teams (teamA and teamB).
* - ownerA owns both the org and teamA.
* - memberB is only a member of teamB (no relation to teamA).
* - An ORGANISATION template is created on teamA.
*/
const seedOrgTemplateScenario = async () => {
const { user: ownerA, organisation, team: teamA } = await seedUser();
const teamBUrl = `team-b-${nanoid()}`;
await createTeam({
userId: ownerA.id,
teamName: `Team B ${teamBUrl}`,
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const teamB = await prisma.team.findFirstOrThrow({
where: { url: teamBUrl },
});
// memberB is only added to teamB, not teamA.
const memberB = await seedTeamMember({
teamId: teamB.id,
role: 'MEMBER',
});
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
},
});
return { ownerA, organisation, teamA, teamB, memberB, orgTemplate };
};
/**
* Helper to make tRPC queries via the authenticated page context.
*/
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = {};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.get(url, { headers });
return { res, json: res.ok() ? await res.json() : null };
};
const trpcMutation = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
) => {
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}`;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (teamId) {
headers['x-team-id'] = teamId.toString();
}
const res = await page.context().request.post(url, {
data: JSON.stringify({ json: input }),
headers,
});
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: ownerA.email,
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
});
test('should not show tabs for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates`,
});
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private Template ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
await expect(page.getByText(privateTemplate.title)).not.toBeVisible();
});
});
// ─── UI: Organisation Template Detail Page ───────────────────────────────────
test.describe('Organisation Templates - Detail Page', () => {
test('should show org template detail but hide edit/delete actions', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation`,
});
// Click into the org template.
await page.getByText(orgTemplate.title).click();
// Should see the template title.
await expect(page.getByRole('heading', { name: orgTemplate.title })).toBeVisible();
// Should see the Use button.
await expect(page.getByRole('button', { name: 'Use' })).toBeVisible();
// Should NOT see the Edit Template button.
await expect(page.getByRole('link', { name: 'Edit Template' })).not.toBeVisible();
});
});
// ─── API: findOrganisationTemplates ──────────────────────────────────────────
test.describe('Organisation Templates - findOrganisationTemplates API', () => {
test('should return org templates for a member of a sibling team', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(res.ok()).toBeTruthy();
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(orgTemplate.title);
});
test('should not return private templates from sibling teams', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private No Show ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(privateTemplate.title);
});
test('should not return org templates to a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
// Create a completely separate user with their own org.
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
outsiderTeam.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
test('should respect document visibility based on the viewer team role', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Everyone ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'EVERYONE',
},
});
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Visibility Admin ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'ADMIN',
},
});
// memberB has role MEMBER on teamB — should only see EVERYONE visibility.
await apiSignin({ page, email: memberB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).toContain(everyoneTemplate.title);
expect(titles).not.toContain(adminOnlyTemplate.title);
await apiSignout({ page });
// ownerA has role ADMIN on teamA — should see both.
await apiSignin({ page, email: ownerA.email });
const { json: adminJson } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamA.id,
);
const adminTitles = adminJson.result.data.json.data.map((t: { title: string }) => t.title);
expect(adminTitles).toContain(everyoneTemplate.title);
expect(adminTitles).toContain(adminOnlyTemplate.title);
});
});
// ─── API: getOrganisationTemplateById ────────────────────────────────────────
test.describe('Organisation Templates - getOrganisationTemplateById API', () => {
test('should allow a sibling team member to fetch an org template', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(res.ok()).toBeTruthy();
expect(json.result.data.json.title).toBe(orgTemplate.title);
});
test('should reject access from a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
// Should fail — outsider is not in the same org.
expect(res.ok()).toBeFalsy();
});
test('should reject fetching a private template via the org endpoint', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { res } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
// Should fail — template is PRIVATE, not ORGANISATION.
expect(res.ok()).toBeFalsy();
});
});
// ─── API: createDocumentFromTemplate with org template ───────────────────────
test.describe('Organisation Templates - Use from different team', () => {
test('should allow creating a document from an org template owned by a sibling team', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
// Add a recipient to the org template so we can use it.
await prisma.recipient.create({
data: {
email: 'recipient@test.documenso.com',
name: 'Recipient',
token: Math.random().toString().slice(2, 7),
envelopeId: orgTemplate.id,
},
});
const orgTemplateWithRecipients = await prisma.envelope.findFirstOrThrow({
where: { id: orgTemplate.id },
include: { recipients: true },
});
await apiSignin({ page, email: memberB.email });
const templateId = mapSecondaryIdToTemplateId(orgTemplateWithRecipients.secondaryId);
const { res } = await trpcMutation(
page,
'template.createDocumentFromTemplate',
{
templateId,
recipients: orgTemplateWithRecipients.recipients.map((r) => ({
id: r.id,
email: r.email,
name: r.name ?? '',
})),
},
teamB.id,
);
expect(res.ok()).toBeTruthy();
});
});
// ─── Adversarial: Cross-organisation access ──────────────────────────────────
test.describe('Organisation Templates - Adversarial', () => {
test('should not allow accessing org template from a different organisation via getEnvelopeById', async ({
page,
}) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
// Try to fetch via the standard envelope.get endpoint.
const { res } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: orgTemplate.id },
outsiderTeam.id,
);
expect(res.ok()).toBeFalsy();
});
test('should not allow a sibling team member to fetch a private template via org endpoint', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Adversarial Private ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
// Attempt 1: Try via org template endpoint.
const { res: orgRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(orgRes.ok()).toBeFalsy();
// Attempt 2: Try via standard envelope endpoint.
const { res: envelopeRes } = await trpcQuery(
page,
'envelope.get',
{ envelopeId: privateTemplate.id },
teamB.id,
);
expect(envelopeRes.ok()).toBeFalsy();
});
test('should not list org templates from a completely unrelated organisation', async ({
page,
}) => {
// Create scenario A.
const { orgTemplate: orgTemplateA } = await seedOrgTemplateScenario();
// Create scenario B (separate org, separate teams).
const { memberB: memberFromOrgB, teamB: teamFromOrgB } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberFromOrgB.email });
const { json } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamFromOrgB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplateA.title);
});
test('should not allow unauthenticated access to org template endpoints', async ({ page }) => {
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
// No apiSignin — unauthenticated.
const { res: findRes } = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
expect(findRes.ok()).toBeFalsy();
expect(findRes.status()).toBe(401);
const { res: getRes } = await trpcQuery(
page,
'template.getOrganisationTemplateById',
{ envelopeId: orgTemplate.id },
teamB.id,
);
expect(getRes.ok()).toBeFalsy();
expect(getRes.status()).toBe(401);
});
test('should not return org template data via findTemplates (team endpoint)', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
// The standard findTemplates endpoint should NOT include org templates from other teams.
const { json } = await trpcQuery(
page,
'template.findTemplates',
{ page: 1, perPage: 50 },
teamB.id,
);
const titles = json.result.data.json.data.map((t: { title: string }) => t.title);
expect(titles).not.toContain(orgTemplate.title);
});
});
+5 -4
View File
@@ -5,9 +5,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:dev": "NODE_OPTIONS='--import tsx' playwright test",
"test-ui:dev": "NODE_OPTIONS='--import tsx' playwright test --ui",
"test:e2e": "NODE_OPTIONS='--import tsx' NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",
@@ -18,10 +18,11 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.20.6",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
"dependencies": {
"start-server-and-test": "^2.1.3"
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
"@oslojs/encoding": "^1.1.0",
"@simplewebauthn/server": "^13.2.2",
"arctic": "^3.7.0",
"hono": "^4.12.2",
"hono": "^4.12.14",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"ts-pattern": "^5.9.0",
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
@@ -3,10 +3,13 @@ import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
@@ -114,6 +117,24 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
}
// Check if signups are disabled.
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
+27 -5
View File
@@ -6,6 +6,7 @@ import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -15,6 +16,7 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
@@ -59,7 +61,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
@@ -83,6 +85,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
await verifyCaptchaToken({
token: captchaToken,
ipAddress: requestMetadata.ipAddress,
});
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
@@ -122,7 +129,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
const isValid = await validateTwoFactorAuthentication({
backupCode,
totpCode,
user,
});
if (!isValid) {
await prisma.userSecurityAuditLog.create({
@@ -178,12 +189,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
const { name, email, password, signature } = c.req.valid('json');
const { name, email, password, signature, captchaToken } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
@@ -197,6 +208,17 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
await verifyCaptchaToken({
token: captchaToken,
ipAddress: requestMetadata.ipAddress,
});
if (!isEmailDomainAllowedForSignup(email)) {
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
+8 -4
View File
@@ -1,16 +1,19 @@
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZSignInSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
csrfToken: z.string().trim(),
captchaToken: z.string().trim().optional(),
});
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
@@ -34,15 +37,16 @@ export const ZPasswordSchema = z
export const ZSignUpSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
email: zEmail(),
password: ZPasswordSchema,
signature: z.string().nullish(),
captchaToken: z.string().trim().optional(),
});
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
export const ZForgotPasswordSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
@@ -61,7 +65,7 @@ export const ZVerifyEmailSchema = z.object({
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;
export const ZResendVerifyEmailSchema = z.object({
email: z.string().email().min(1),
email: zEmail().min(1),
});
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
@@ -111,41 +111,40 @@ export const createEmailDomain = async ({ domain, organisationId }: CreateEmailD
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
// Verify domain with SES outside a transaction to avoid holding a
// connection open during the external API call.
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
lastVerifiedAt: true,
emails: true,
},
});
const emailDomain = await prisma.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
lastVerifiedAt: true,
emails: true,
},
});
return {
@@ -103,61 +103,59 @@ export const linkOrganisationAccount = async ({
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
// Only add the user to the organisation if they are not already a member.
if (!organisationMember) {
await addUserToOrganisation({
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole:
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
},
{ timeout: 30_000 },
);
});
}
// Only add the user to the organisation if they are not already a member.
// Done outside the above transaction to avoid nested transactions and
// holding connections during the job trigger network I/O.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
};
@@ -22,6 +22,12 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: TLimitsResponseSchema;
/**
* Bypass limits for embed authoring. This is just client side bypass since
* all embeds should be paid plans.
*/
disableLimitsFetch?: boolean;
teamId: number;
children?: React.ReactNode;
};
@@ -32,12 +38,17 @@ export const LimitsProvider = ({
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
},
disableLimitsFetch,
teamId,
children,
}: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = useCallback(async () => {
if (disableLimitsFetch) {
return;
}
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
@@ -54,6 +65,10 @@ export const LimitsProvider = ({
}, [refreshLimits]);
useEffect(() => {
if (disableLimitsFetch) {
return;
}
const onFocus = () => {
void refreshLimits();
};
@@ -27,6 +27,7 @@ export const createCheckoutSession = async ({
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
billing_address_collection: 'required',
subscription_data: {
metadata: subscriptionMetadata,
},
@@ -50,55 +50,88 @@ export const updateSubscriptionItemQuantity = async ({
};
/**
* Checks whether the member count should be synced with a given Stripe subscription.
* Asserts that a proposed member count does not exceed the organisation's cap.
*
* If the subscription is not "seat" based, it will be ignored.
* Only enforced for non-seats-based plans, since seats-based plans meter usage
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
* organisation claim represents unlimited seats.
*
* @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim
* @param quantity - The amount to sync the Stripe item with
* @returns
* Should only be called from grow paths (invite/add). Reducing operations
* must never be gated by this check.
*
* @param subscription - The organisation's Stripe subscription.
* @param organisationClaim - The organisation claim.
* @param quantity - The proposed total member + pending invite count.
*/
export const syncMemberCountWithStripeSeatPlan = async (
export const assertMemberCountWithinCap = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
const maximumMemberCount = organisationClaim.memberCount;
// Infinite seats means no sync needed.
// 0 = unlimited.
if (maximumMemberCount === 0) {
return;
}
const syncMemberCountWithStripe = await isPriceSeatsBased(subscription.priceId);
// Seats-based plans don't have a hard cap; Stripe meters the usage.
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
// Throw error if quantity exceeds maximum member count and the subscription is not seats based.
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) {
if (isSeatsBased) {
return;
}
if (quantity > maximumMemberCount) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'Maximum member count reached',
});
}
// Bill the user with the new quantity.
if (syncMemberCountWithStripe) {
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// This should be automatically updated after the Stripe webhook is fired
// but we just manually adjust it here as well to avoid any race conditions.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
}
};
/**
* Syncs the organisation's member count with the Stripe subscription quantity.
*
* No-ops for plans that are not seats-based, and for organisations with
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
* both grow and shrink paths.
*
* @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim.
* @param quantity - The new total member + pending invite count to sync.
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
// Infinite seats means no sync needed.
if (organisationClaim.memberCount === 0) {
return;
}
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
if (!isSeatsBased) {
return;
}
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// This should be automatically updated after the Stripe webhook is fired
// but we just manually adjust it here as well to avoid any race conditions.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
};
+5 -1
View File
@@ -64,9 +64,13 @@ const getTransport = (): Transporter => {
}
if (transport === 'resend') {
if (!env('NEXT_PRIVATE_RESEND_API_KEY')) {
throw new Error('Resend transport requires NEXT_PRIVATE_RESEND_API_KEY');
}
return createTransport(
ResendTransport.makeTransport({
apiKey: env('NEXT_PRIVATE_RESEND_API_KEY') || '',
apiKey: env('NEXT_PRIVATE_RESEND_API_KEY'),
}),
);
}
+2 -2
View File
@@ -37,12 +37,12 @@
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^7.0.10",
"nodemailer": "^8.0.5",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^7.0.4"
"@types/nodemailer": "^8.0.0"
}
}
@@ -0,0 +1,77 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentReminderProps {
recipientName: string;
documentName: string;
signDocumentLink: string;
assetBaseUrl: string;
role: RecipientRole;
}
export const TemplateDocumentReminder = ({
recipientName,
documentName,
signDocumentLink,
assetBaseUrl,
role,
}: TemplateDocumentReminderProps) => {
const { _ } = useLingui();
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>
Reminder: Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Hi {recipientName},</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => (
<Trans>Continue by assisting with the document.</Trans>
))
.exhaustive()}
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()}
</Button>
</Section>
</Section>
</>
);
};
export default TemplateDocumentReminder;
@@ -0,0 +1,91 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { RecipientRole } from '@prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body';
import { TemplateDocumentReminder } from '../template-components/template-document-reminder';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentReminderEmailTemplateProps = {
recipientName: string;
documentName: string;
signDocumentLink: string;
assetBaseUrl?: string;
customBody?: string;
role: RecipientRole;
};
export const DocumentReminderEmailTemplate = ({
recipientName = 'John Doe',
documentName = 'Open Source Pledge.pdf',
signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
customBody,
role = RecipientRole.SIGNER,
}: DocumentReminderEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase();
const previewText = msg`Reminder to ${action} ${documentName}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateDocumentReminder
recipientName={recipientName}
documentName={documentName}
signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl}
role={role}
/>
</Section>
</Container>
{customBody && (
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="mt-2 text-base text-slate-400">
<TemplateCustomMessageBody text={customBody} />
</Text>
</Section>
</Container>
)}
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Html>
);
};
export default DocumentReminderEmailTemplate;
+5 -7
View File
@@ -54,13 +54,11 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
const from: MailChannelsAddress =
typeof mail.data.from === 'string'
? { email: mail.data.from }
: {
email: mail.data.from?.address,
name: mail.data.from?.name,
};
const [from] = this.toMailChannelsAddresses(mail.data.from);
if (!from) {
return callback(new Error('Missing required field "from"'), null);
}
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
+2 -3
View File
@@ -1,5 +1,5 @@
module.exports = {
extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
extends: ['turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
plugins: ['unused-imports'],
@@ -22,8 +22,7 @@ module.exports = {
},
rules: {
'@next/next/no-html-link-for-pages': 'off',
'react/no-unescaped-entities': 'off',
// 'react/no-unescaped-entities': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'warn',
+1 -2
View File
@@ -10,11 +10,10 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.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.3.0",
"typescript": "5.6.2"
}
}
}
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => {
export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
const target = event.target;
const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
target.closest<HTMLElement>(pageSelector) ??
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => el.matches(pageSelector));
if (!$page) {
return null;
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
id: z.number().optional(),
@@ -37,7 +37,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
@@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
@@ -222,14 +222,16 @@ export const useEditorFields = ({
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
const totalPages = getPdfPagesCount();
const newFields: TLocalField[] = [];
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return newFields;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === field.page) {
return;
continue;
}
const newField: TLocalField = {
@@ -241,7 +243,7 @@ export const useEditorFields = ({
append(newField);
newFields.push(newField);
});
}
triggerFieldsUpdate();
return newFields;
@@ -1,7 +1,7 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -11,10 +11,9 @@ import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -36,12 +35,12 @@ export const ZEditorRecipientsFormSchema = z.object({
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
@@ -17,7 +17,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
return { top: 0, left: 0, width: 0, height: 0 };
}
if (withScroll) {
@@ -36,7 +36,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
useEffect(() => {
setBounds(calculateBounds());
}, []);
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
@@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
}, [calculateBounds]);
useEffect(() => {
const $el =
@@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, []);
}, [elementOrSelector, calculateBounds]);
return bounds;
};
@@ -3,7 +3,10 @@ import { useCallback, useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
@@ -57,23 +60,65 @@ export const useFieldPageCoords = (
};
}, [calculateCoords]);
// Watch for the page element to appear in the DOM (e.g. after a virtual list
// scroll) and recalculate coords. Also attach a ResizeObserver once the page
// element exists.
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
if (!$page) {
return;
let resizeObserver: ResizeObserver | null = null;
let observedElement: HTMLElement | null = null;
const attachResizeObserver = ($page: HTMLElement) => {
if ($page === observedElement) {
return;
}
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
calculateCoords();
});
resizeObserver.observe($page);
observedElement = $page;
};
// Try to attach immediately if the page already exists.
const existingPage = document.querySelector<HTMLElement>(pageSelector);
if (existingPage) {
attachResizeObserver(existingPage);
}
const observer = new ResizeObserver(() => {
// Watch for DOM mutations to detect when the page element appears (e.g.
// after the virtual list scrolls to a new page and renders it).
// Scope to the PDF viewer content container to avoid firing on unrelated
// DOM changes elsewhere in the document.
const mutationObserver = new MutationObserver(() => {
const $page = document.querySelector<HTMLElement>(pageSelector);
if (!$page) {
return;
}
if ($page === observedElement) {
return;
}
calculateCoords();
attachResizeObserver($page);
});
observer.observe($page);
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
mutationObserver.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
mutationObserver.disconnect();
resizeObserver?.disconnect();
observedElement = null;
};
}, [calculateCoords, field.page]);
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
import {
PDF_VIEWER_CONTENT_SELECTOR,
PDF_VIEWER_PAGE_SELECTOR,
} from '@documenso/lib/constants/pdf-viewer';
/**
* Returns whether the PDF page element for the given page number is currently
* present in the DOM. With virtual list rendering only pages near the viewport
* are mounted, so this hook lets consumers skip rendering when their page is
* virtualised away.
*/
export const useIsPageInDom = (pageNumber: number) => {
const [isPageInDom, setIsPageInDom] = useState(false);
useEffect(() => {
const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
setIsPageInDom(document.querySelector(selector) !== null);
const observer = new MutationObserver(() => {
setIsPageInDom(document.querySelector(selector) !== null);
});
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
observer.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [pageNumber]);
return isPageInDom;
};
@@ -1,135 +1,86 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { type PageRenderData } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
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.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
() => ({
scale: 1,
width: pageWidth,
height: pageHeight,
}),
[pageWidth, pageHeight],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
() => ({
scale,
width: pageWidth * scale,
height: pageHeight * scale,
}),
[pageWidth, pageHeight, scale],
);
/**
* Viewport with the device pixel ratio applied so we can render the PDF
* in a higher resolution.
*/
const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
[page, rotate, scale],
);
useEffect(() => {
const { current: container } = konvaContainer;
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
if (!container || imageLoadingState !== 'loaded') {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
stage.current = new Konva.Stage({
container,
id: `page-${pageNumber}`,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
if (!canvas || !kContainer) {
return;
}
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
stage.current.add(pageLayer.current);
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
const renderContext: RenderParameters = {
canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return () => {
stage.current?.destroy();
stage.current = null;
};
}, [imageLoadingState, scaledViewport]);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}
};
@@ -1,51 +1,44 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { useSearchParams } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import {
DEFAULT_EDITOR_CONFIG,
type EnvelopeEditorConfig,
type TEditorEnvelope,
} from '@documenso/lib/types/envelope-editor';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number,
) => {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(...args: Args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
};
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
editorConfig: EnvelopeEditorConfig;
envelope: TEditorEnvelope;
isEmbedded: boolean;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
@@ -57,8 +50,9 @@ type EnvelopeEditorProviderValue = {
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean;
resetForms: () => void;
relativePath: {
basePath: string;
@@ -68,12 +62,20 @@ type EnvelopeEditorProviderValue = {
templateRootPath: string;
};
navigateToStep: (step: EnvelopeEditorStep) => void;
syncEnvelope: () => Promise<void>;
registerExternalFlush: (key: string, flush: () => Promise<void>) => () => void;
registerPendingMutation: (promise: Promise<unknown>) => void;
organisationEmails?: { id: string; email: string }[];
};
interface EnvelopeEditorProviderProps {
children: React.ReactNode;
initialEnvelope: TEnvelope;
editorConfig?: EnvelopeEditorConfig;
initialEnvelope: TEditorEnvelope;
organisationEmails?: { id: string; email: string }[];
}
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
@@ -90,14 +92,49 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({
children,
editorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope,
organisationEmails,
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
externalFlushCallbacksRef.current.set(key, flush);
return () => {
externalFlushCallbacksRef.current.delete(key);
};
}, []);
const registerPendingMutation = useCallback((promise: Promise<unknown>) => {
pendingMutationsRef.current.add(promise);
void promise.finally(() => {
pendingMutationsRef.current.delete(promise);
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
@@ -107,61 +144,35 @@ export const EnvelopeEditorProvider = ({
envelope,
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
setEnvelope({
...envelope,
...response,
documentMeta: {
...envelope.documentMeta,
...input.meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (input.meta?.emailSettings ||
null) as unknown as TDocumentEmailSettings | null,
},
});
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
const setFieldsMutation = trpc.envelope.field.set.useMutation();
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
/**
* Handles debouncing the recipients updates to the server.
*
* Will set the local envelope recipients and fields after the update is complete.
*/
const {
triggerSave: setRecipientsDebounced,
flush: flushSetRecipients,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
try {
let recipients: TEditorEnvelope['recipients'] = [];
setAutosaveError(true);
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: ({ data: fields }) => {
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
@@ -178,8 +189,7 @@ export const EnvelopeEditorProvider = ({
);
setAutosaveError(false);
},
onError: (err) => {
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -190,58 +200,137 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive',
duration: 7500,
});
},
});
const {
triggerSave: setRecipientsDebounced,
flush: setRecipientsAsync,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
await envelopeRecipientSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients,
});
}
}, 1000);
const setRecipientsAsync = async (
localRecipients: TSetEnvelopeRecipientsRequest['recipients'],
) => {
setRecipientsDebounced(localRecipients);
await flushSetRecipients();
};
/**
* Handles debouncing the fields updates to the server.
*
* Will set the local envelope fields after the update is complete.
*/
const {
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
flush: flushSetFields,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
// Insert the IDs into the local fields.
envelopeFields.data.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
}
});
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
// Insert the IDs into the local fields.
fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
} catch (err) {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
}
}, 2000);
const setFieldsAsync = async (localFields: TLocalField[]) => {
setFieldsDebounced(localFields);
await flushSetFields();
};
/**
* Handles debouncing the envelope updates to the server.
*
* Will set the local envelope after the update is complete.
*/
const {
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
triggerSave: updateEnvelopeDebounced,
flush: flushUpdateEnvelope,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
data,
meta,
})
: {};
setEnvelope((prev) => ({
...prev,
...data,
authOptions: {
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
},
...response,
documentMeta: {
...prev.documentMeta,
...meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
},
}));
setAutosaveError(false);
} catch (err) {
console.error(err);
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
}
}, 1000);
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
updateEnvelopeDebounced(envelopeUpdates);
await flushUpdateEnvelope();
};
/**
* Updates the local envelope and debounces the update to the server.
*
* Use this when you want to update the local envelope immediately while debouncing
* the actual update to the server.
*/
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({
@@ -253,35 +342,23 @@ export const EnvelopeEditorProvider = ({
},
}));
setEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
updateEnvelopeDebounced(envelopeUpdates);
};
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) =>
getRecipientColor(envelope.recipients.findIndex((r) => r.id === recipientId)),
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
const { refetch: reloadEnvelope } = trpc.envelope.editor.get.useQuery(
{
envelopeId: envelope.id,
},
{
initialData: envelope,
enabled: !isEmbedded,
gcTime: 0,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -293,6 +370,11 @@ export const EnvelopeEditorProvider = ({
const syncEnvelope = async () => {
await flushAutosave();
// Bypass syncing for embedded mode.
if (isEmbedded) {
return;
}
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
@@ -302,55 +384,95 @@ export const EnvelopeEditorProvider = ({
recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta,
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
const isAutosaving = useMemo(() => {
return (
envelopeFieldSetMutationQuery.isPending ||
envelopeRecipientSetMutationQuery.isPending ||
envelopeUpdateMutationQuery.isPending ||
isFieldsMutationPending ||
isRecipientsMutationPending ||
isEnvelopeMutationPending
);
}, [
envelopeFieldSetMutationQuery.isPending,
envelopeRecipientSetMutationQuery.isPending,
envelopeUpdateMutationQuery.isPending,
isFieldsMutationPending,
isRecipientsMutationPending,
isEnvelopeMutationPending,
]);
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url);
let documentRootPath = formatDocumentsPath(envelope.team.url);
let templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
let envelopePath = `${basePath}/${envelope.id}`;
let editorPath = `${basePath}/${envelope.id}/edit`;
if (editorConfig.embedded) {
let embeddedEditorPath =
editorConfig.embedded.mode === 'edit'
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
: `/embed/v2/authoring/envelope/create`;
embeddedEditorPath += `?token=${editorConfig.embedded.presignToken}`;
envelopePath = embeddedEditorPath;
editorPath = embeddedEditorPath;
documentRootPath = embeddedEditorPath;
templateRootPath = embeddedEditorPath;
}
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
envelopePath,
editorPath,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
if (step === 'upload') {
newParams.delete('step');
} else {
newParams.set('step', step);
}
return newParams;
});
};
const resetForms = () => {
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
// Flush all registered external flushes (e.g., upload page's debounced item updates).
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
await Promise.all(externalFlushes.map(async (flush) => flush()));
// Await all registered pending mutations (e.g., in-flight creates/deletes).
// Use allSettled so a single failed mutation doesn't prevent awaiting the rest.
if (pendingMutationsRef.current.size > 0) {
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
};
return (
<EnvelopeEditorContext.Provider
value={{
editorConfig,
envelope,
isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
@@ -366,9 +488,113 @@ export const EnvelopeEditorProvider = ({
isAutosaving,
relativePath,
syncEnvelope,
navigateToStep,
resetForms,
registerExternalFlush,
registerPendingMutation,
organisationEmails,
}}
>
{children}
</EnvelopeEditorContext.Provider>
);
};
type MapLocalRecipientsToRecipientsOptions = {
envelope: TEditorEnvelope;
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
};
const mapLocalRecipientsToRecipients = ({
envelope,
localRecipients,
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
if (recipient.id && recipient.id < min) {
return recipient.id;
}
return min;
}, -1);
return localRecipients.map((recipient) => {
const foundRecipient = envelope.recipients.find((r) => r.id === recipient.id);
let recipientId = recipient.id;
if (recipientId === undefined) {
recipientId = smallestRecipientId;
smallestRecipientId--;
}
return {
id: recipientId,
envelopeId: envelope.id,
email: recipient.email,
name: recipient.name,
token: foundRecipient?.token || '',
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
expired: foundRecipient?.expired || null,
signedAt: foundRecipient?.signedAt || null,
authOptions:
recipient.actionAuth.length > 0
? { actionAuth: recipient.actionAuth, accessAuth: [] }
: null,
signingOrder: recipient.signingOrder ?? null,
rejectionReason: foundRecipient?.rejectionReason || null,
role: recipient.role,
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
expiresAt: foundRecipient?.expiresAt || null,
expirationNotifiedAt: foundRecipient?.expirationNotifiedAt || null,
};
});
};
type MapLocalFieldsToFieldsOptions = {
localFields: TLocalField[];
envelope: TEditorEnvelope;
};
const mapLocalFieldsToFields = ({
envelope,
localFields,
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
let smallestFieldId = localFields.reduce((min, field) => {
if (field.id && field.id < min) {
return field.id;
}
return min;
}, -1);
return localFields.map((field) => {
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
let fieldId = field.id;
if (fieldId === undefined) {
fieldId = smallestFieldId;
smallestFieldId--;
}
return {
...field,
formId: field.formId,
id: fieldId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
type: field.type,
recipientId: field.recipientId,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
secondaryId: foundField?.secondaryId || '',
inserted: foundField?.inserted || false,
customText: foundField?.customText || '',
fieldMeta: field.fieldMeta || null,
};
});
};
@@ -1,23 +1,26 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import { type Field, type Recipient } from '@prisma/client';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
type FileData =
| {
status: 'loading' | 'error';
}
| {
file: Uint8Array;
status: 'loaded';
};
export type PageRenderData = {
scale: number;
pageIndex: number;
pageNumber: number;
pageWidth: number;
pageHeight: number;
imageLoadingState: ImageLoadingState;
};
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
@@ -25,10 +28,22 @@ type EnvelopeRenderOverrideSettings = {
showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderItem = {
id: string;
title: string;
order: number;
envelopeId: string;
/**
* The PDF data to render.
*
* If it's a string we assume it's a URL to the PDF file.
*/
data: Uint8Array | string;
};
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
@@ -46,7 +61,26 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
/**
* The envelope item version to render.
*/
version: DocumentDataVersion;
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
/**
* The envelope items to render.
*
* If data is optional then we build the URL based of the IDs.
*/
envelopeItems: {
id: string;
title: string;
order: number;
envelopeId: string;
documentDataId: string;
data?: Uint8Array | string;
}[];
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@@ -70,6 +104,13 @@ interface EnvelopeRenderProviderProps {
*/
token: string | undefined;
/**
* The presign token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
presignToken?: string | undefined;
/**
* Custom override settings for generic page renderers.
*/
@@ -89,89 +130,61 @@ export const useCurrentEnvelopeRender = () => {
};
/**
* Manages fetching and storing PDF files to render on the client.
* Manages fetching the data required to render an envelope and it's items.
*/
export const EnvelopeRenderProvider = ({
children,
envelope,
envelopeItems: envelopeItemsFromProps,
fields,
token,
presignToken,
recipients = [],
version,
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
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],
() =>
[...envelopeItemsFromProps]
.sort((a, b) => a.order - b.order)
.map((item) => {
const pdfUrl = getDocumentDataUrl({
envelopeId: envelope.id,
envelopeItemId: item.id,
documentDataId: item.documentDataId,
version,
token,
presignToken,
});
const data = item.data || pdfUrl;
return {
...item,
data,
};
}),
[envelopeItemsFromProps, envelope.id, token, version, presignToken],
);
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
return;
}
const [currentItemId, setCurrentItemId] = useState<string | null>(envelopeItems[0]?.id ?? null);
if (!files[envelopeItem.id]) {
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'loading',
},
}));
}
try {
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,
[envelopeItem.id]: {
file: new Uint8Array(file),
status: 'loaded',
},
}));
} catch (error) {
console.error(error);
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'error',
},
}));
}
};
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
},
[files],
);
const currentItem = useMemo((): EnvelopeRenderItem | null => {
return envelopeItems.find((item) => item.id === currentItemId) ?? null;
}, [currentItemId, envelopeItems]);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null);
setCurrentItemId(foundItem?.id ?? 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);
setCurrentItemId(null);
}
if (!currentItem && envelopeItems.length > 0) {
@@ -179,35 +192,20 @@ export const EnvelopeRenderProvider = ({
}
}, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
for (const item of missingFiles) {
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);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) => getRecipientColor(recipientIds.findIndex((id) => id === recipientId)),
[recipientIds],
);
return (
<EnvelopeRenderContext.Provider
value={{
getPdfBuffer,
version,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
+18 -1
View File
@@ -71,11 +71,28 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
const organisations = await trpc.organisation.internal.getOrganisationSession
.query(undefined, SKIP_QUERY_BATCH_META.trpc)
.catch(() => {
.catch((e) => {
const errorMessage = typeof e.message === 'string' ? e.message.toLowerCase() : '';
const isNetworkError =
errorMessage.includes('networkerror') || errorMessage.includes('failed to fetch');
// If the error is a transient network/abort error (e.g. page refresh while
// fetch was in-flight), return null to signal we should skip the state update.
if (isNetworkError) {
return null;
}
// Todo: (RR7) Log
return [];
});
// Skip session update if the organisation fetch was aborted due to a transient
// network error (e.g. page refresh while fetch was in-flight).
if (organisations === null) {
return;
}
setSession({
session: newSession.session,
user: newSession.user,
+4 -2
View File
@@ -7,6 +7,8 @@ import {
SigningStatus,
} from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
@@ -16,7 +18,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
recipient: Recipient,
recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
+37
View File
@@ -69,3 +69,40 @@ export const getCookieDomain = () => {
return url.hostname;
};
/**
* Get allowed signup domains from env var.
* Returns empty array if not set (meaning all domains allowed).
*/
export const getAllowedSignupDomains = (): string[] => {
const domains = env('NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS');
if (!domains) {
return [];
}
return domains
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
};
/**
* Check if email domain is allowed for signup.
* Returns true if no domain restriction is configured.
*/
export const isEmailDomainAllowedForSignup = (email: string): boolean => {
const allowedDomains = getAllowedSignupDomains();
if (allowedDomains.length === 0) {
return true;
}
const emailDomain = email.toLowerCase().split('@').pop();
if (!emailDomain) {
return false;
}
return allowedDomains.includes(emailDomain);
};
+13 -3
View File
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { SubscriptionStatus } from '@prisma/client';
export enum STRIPE_PLAN_TYPE {
@@ -12,7 +13,16 @@ export enum STRIPE_PLAN_TYPE {
export const FREE_TIER_DOCUMENT_QUOTA = 5;
export const SUBSCRIPTION_STATUS_MAP = {
[SubscriptionStatus.ACTIVE]: 'Active',
[SubscriptionStatus.INACTIVE]: 'Inactive',
[SubscriptionStatus.PAST_DUE]: 'Past Due',
[SubscriptionStatus.ACTIVE]: msg({
message: 'Active',
context: 'Subscription status',
}),
[SubscriptionStatus.INACTIVE]: msg({
message: 'Inactive',
context: 'Subscription status',
}),
[SubscriptionStatus.PAST_DUE]: msg({
message: 'Past Due',
context: 'Subscription status',
}),
};
@@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
[DOCUMENT_EMAIL_TYPE.REMINDER]: {
description: 'Signing Reminder',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
+6
View File
@@ -9,6 +9,12 @@ import { DocumentSignatureType } from '@documenso/lib/utils/teams';
export { DocumentSignatureType };
/**
* Maximum count returned per status bucket in document stats. The server clamps
* each count to this value; the UI should display "10,000+" when it sees it.
*/
export const STATS_COUNT_CAP = 10_000;
export const DOCUMENT_STATUS: {
[status in DocumentStatus]: { description: MessageDescriptor };
} = {
@@ -0,0 +1,86 @@
import type { DurationLikeObject } from 'luxon';
import { Duration } from 'luxon';
import { z } from 'zod';
export const ZEnvelopeReminderDurationPeriod = z.object({
unit: z.enum(['day', 'week', 'month']),
amount: z.number().int().min(1),
});
export const ZEnvelopeReminderDisabledPeriod = z.object({
disabled: z.literal(true),
});
export const ZEnvelopeReminderPeriod = z.union([
ZEnvelopeReminderDurationPeriod,
ZEnvelopeReminderDisabledPeriod,
]);
export type TEnvelopeReminderPeriod = z.infer<typeof ZEnvelopeReminderPeriod>;
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
export const ZEnvelopeReminderSettings = z.object({
sendAfter: ZEnvelopeReminderPeriod,
repeatEvery: ZEnvelopeReminderPeriod,
});
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
sendAfter: { unit: 'day', amount: 5 },
repeatEvery: { unit: 'day', amount: 2 },
};
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
{
day: 'days',
week: 'weeks',
month: 'months',
};
export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
/**
* Resolve the next reminder timestamp from the config and the last reminder sent time.
*
* - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
if (!config) {
return null;
}
// If we haven't sent the first reminder yet, use sendAfter.
if (!lastReminderSentAt) {
if ('disabled' in config.sendAfter) {
return null;
}
const delay = getEnvelopeReminderDuration(config.sendAfter);
return new Date(sentAt.getTime() + delay.toMillis());
}
// For subsequent reminders, use repeatEvery.
if ('disabled' in config.repeatEvery) {
return null;
}
const interval = getEnvelopeReminderDuration(config.repeatEvery);
return new Date(lastReminderSentAt.getTime() + interval.toMillis());
};
-3
View File
@@ -1,13 +1,10 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES, type SupportedLanguageCodes } from './locales';
export * from './locales';
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
export type I18nLocaleData = {
/**
* The supported language extracted from the locale.
+4
View File
@@ -1,3 +1,5 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
@@ -19,3 +21,5 @@ export const APP_I18N_OPTIONS = {
sourceLang: 'en',
defaultLocale: 'en-US',
} as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
+22
View File
@@ -0,0 +1,22 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
export const PDF_VIEWER_ERROR_MESSAGES = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
default: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
},
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
+18 -1
View File
@@ -1,2 +1,19 @@
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
// Keep these two constants in sync.
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
export const PDF_VIEWER_CONTENT_SELECTOR = '[data-pdf-content]';
export const getPdfPagesCount = () => {
const pageCountAttr = document
.querySelector(PDF_VIEWER_CONTENT_SELECTOR)
?.getAttribute('data-page-count');
const totalPages = Number(pageCountAttr);
if (!Number.isInteger(totalPages) || totalPages < 1) {
return 0;
}
return totalPages;
};
+3
View File
@@ -13,12 +13,14 @@ export enum AppErrorCode {
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@@ -28,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
+8
View File
@@ -1,6 +1,7 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -15,7 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -29,16 +33,20 @@ export const jobsClient = new JobClient([
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const);
+387
View File
@@ -0,0 +1,387 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import type { Job } from 'bullmq';
import { Hono } from 'hono';
import type { Context as HonoContext } from 'hono';
import IORedis from 'ioredis';
import { createRequire } from 'node:module';
import path from 'node:path';
import { prisma } from '@documenso/prisma';
import { env } from '../../utils/env';
import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job';
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
const QUEUE_NAME = 'documenso-jobs';
const DEFAULT_CONCURRENCY = 10;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_DELAY = 1000;
declare global {
// eslint-disable-next-line no-var
var __documenso_bullmq_provider__: BullMQJobProvider | undefined;
}
export class BullMQJobProvider extends BaseJobProvider {
private _queue: Queue;
private _worker: Worker;
private _connection: IORedis;
private _jobDefinitions: Record<string, JobDefinition> = {};
private constructor() {
super();
const redisUrl = env('NEXT_PRIVATE_REDIS_URL');
if (!redisUrl) {
throw new Error(
'[JOBS]: NEXT_PRIVATE_REDIS_URL is required when using the BullMQ jobs provider',
);
}
const prefix = env('NEXT_PRIVATE_REDIS_PREFIX') || 'documenso';
this._connection = new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
this._queue = new Queue(QUEUE_NAME, {
connection: this._connection,
prefix,
});
const concurrency = Number(env('NEXT_PRIVATE_BULLMQ_CONCURRENCY')) || DEFAULT_CONCURRENCY;
this._worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
await this.processJob(job);
},
{
connection: this._connection,
prefix,
concurrency,
},
);
this._worker.on('failed', (job, error) => {
console.error(`[JOBS]: Job ${job?.name ?? 'unknown'} failed`, error);
});
this._worker.on('error', (error) => {
console.error('[JOBS]: Worker error', error);
});
console.log(`[JOBS]: BullMQ provider initialized (concurrency: ${concurrency})`);
}
/**
* Uses globalThis to store the singleton instance so that it's shared across
* different bundles (e.g. Hono and Vite/React Router) at runtime.
*/
static getInstance() {
if (globalThis.__documenso_bullmq_provider__) {
return globalThis.__documenso_bullmq_provider__;
}
const instance = new BullMQJobProvider();
globalThis.__documenso_bullmq_provider__ = instance;
return instance;
}
public defineJob<N extends string, T>(definition: JobDefinition<N, T>) {
this._jobDefinitions[definition.id] = {
...definition,
enabled: definition.enabled ?? true,
};
if (definition.trigger.cron && definition.enabled !== false) {
void this._queue
.upsertJobScheduler(
definition.id,
{ pattern: definition.trigger.cron },
{
name: definition.id,
data: {
name: definition.trigger.name,
payload: {},
},
opts: {
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
},
)
.then(() => {
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
})
.catch((error) => {
console.error(`[JOBS]: Failed to register cron job ${definition.id}`, error);
});
}
}
public async triggerJob(options: SimpleTriggerJobOptions) {
const eligibleJobs = Object.values(this._jobDefinitions).filter(
(job) => job.trigger.name === options.name,
);
await Promise.all(
eligibleJobs.map(async (job) => {
const backgroundJob = await prisma.backgroundJob.create({
data: {
jobId: job.id,
name: job.name,
version: job.version,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
payload: options.payload as Prisma.InputJsonValue,
},
});
await this._queue.add(
job.id,
{
name: options.name,
payload: options.payload,
backgroundJobId: backgroundJob.id,
},
{
jobId: options.id,
attempts: DEFAULT_MAX_RETRIES,
backoff: {
type: 'exponential',
delay: DEFAULT_BACKOFF_DELAY,
},
},
);
}),
);
}
public override getApiHandler(): (c: HonoContext) => Promise<Response | void> {
const boardApp = this.createBoardApp();
return async (c: HonoContext) => {
const reqPath = new URL(c.req.url).pathname;
if (!reqPath.startsWith('/api/jobs/board')) {
return c.text('OK', 200);
}
// Auth check — open in dev, admin-only in production.
if (env('NODE_ENV') !== 'development') {
const { getOptionalSession } = await import('@documenso/auth/server/lib/utils/get-session');
const { isAdmin } = await import('../../utils/is-admin');
const { user } = await getOptionalSession(c);
if (!user || !isAdmin(user)) {
return c.text('Unauthorized', 401);
}
}
return boardApp.fetch(c.req.raw);
};
}
private createBoardApp(): Hono {
const _require = createRequire(import.meta.url);
const uiPackagePath = path.dirname(_require.resolve('@bull-board/ui/package.json'));
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
queues: [new BullMQAdapter(this._queue)],
serverAdapter,
options: { uiBasePath: uiPackagePath },
});
serverAdapter.setBasePath('/api/jobs/board');
const app = new Hono();
app.route('/api/jobs/board', serverAdapter.registerPlugin());
return app;
}
private async processJob(job: Job) {
const definitionId = job.name;
const definition = this._jobDefinitions[definitionId];
if (!definition) {
console.error(`[JOBS]: No definition found for job ${definitionId}`);
throw new Error(`No definition found for job ${definitionId}`);
}
if (!definition.enabled) {
console.log(`[JOBS]: Skipping disabled job ${definitionId}`);
return;
}
const jobData = job.data as {
name: string;
payload: unknown;
backgroundJobId?: string;
};
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(jobData.payload);
if (!result.success) {
console.error(`[JOBS]: Payload validation failed for ${definitionId}`, result.error);
throw new Error(`Payload validation failed for ${definitionId}`);
}
}
const backgroundJobId = jobData.backgroundJobId;
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: {
id: backgroundJobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: job.attemptsMade > 0 ? job.attemptsMade : 0,
lastRetriedAt: job.attemptsMade > 0 ? new Date() : undefined,
},
})
.catch(() => null);
}
console.log(`[JOBS]: Processing job ${definitionId} with payload`, jobData.payload);
try {
await definition.handler({
payload: jobData.payload,
io: this.createJobRunIO(backgroundJobId ?? job.id ?? definitionId),
});
if (backgroundJobId) {
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
})
.catch(() => null);
}
} catch (error) {
if (backgroundJobId) {
const isFinalAttempt = job.attemptsMade >= (job.opts.attempts ?? DEFAULT_MAX_RETRIES) - 1;
await prisma.backgroundJob
.update({
where: { id: backgroundJobId },
data: {
status: isFinalAttempt ? BackgroundJobStatus.FAILED : BackgroundJobStatus.PENDING,
completedAt: isFinalAttempt ? new Date() : undefined,
},
})
.catch(() => null);
}
throw error;
}
}
private createJobRunIO(jobId: string): JobRunIO {
return {
runTask: async <T extends void | Json>(cacheKey: string, callback: () => Promise<T>) => {
const hashedKey = Buffer.from(sha256(cacheKey)).toString('hex');
let task = await prisma.backgroundJobTask.findFirst({
where: {
id: `task-${hashedKey}--${jobId}`,
jobId,
},
});
if (!task) {
task = await prisma.backgroundJobTask.create({
data: {
id: `task-${hashedKey}--${jobId}`,
name: cacheKey,
jobId,
status: BackgroundJobStatus.PENDING,
},
});
}
if (task.status === BackgroundJobStatus.COMPLETED) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return task.result as T;
}
if (task.retried >= DEFAULT_MAX_RETRIES) {
throw new Error('Task exceeded retries');
}
try {
const result = await callback();
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.COMPLETED,
result: result === null ? Prisma.JsonNull : result,
completedAt: new Date(),
},
});
return result;
} catch (err) {
await prisma.backgroundJobTask.update({
where: {
id: task.id,
jobId,
},
data: {
status: BackgroundJobStatus.PENDING,
retried: {
increment: 1,
},
},
});
console.log(`[JOBS:${task.id}] Task failed`, err);
throw err;
}
},
triggerJob: async (_cacheKey, payload) => await this.triggerJob(payload),
logger: {
debug: (...args) => console.debug(`[${jobId}]`, ...args),
error: (...args) => console.error(`[${jobId}]`, ...args),
info: (...args) => console.info(`[${jobId}]`, ...args),
log: (...args) => console.log(`[${jobId}]`, ...args),
warn: (...args) => console.warn(`[${jobId}]`, ...args),
},
// eslint-disable-next-line @typescript-eslint/require-await
wait: async () => {
throw new Error('Not implemented');
},
};
}
}
+2
View File
@@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { env } from '../../utils/env';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { BullMQJobProvider } from './bullmq';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
@@ -12,6 +13,7 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public constructor(definitions: T) {
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
.with('inngest', () => InngestJobProvider.getInstance())
.with('bullmq', () => BullMQJobProvider.getInstance())
.otherwise(() => LocalJobProvider.getInstance());
definitions.forEach((definition) => {
@@ -1,11 +1,12 @@
import { z } from 'zod';
import { zEmail } from '../../../utils/zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
email: z.string().email(),
email: zEmail(),
force: z.boolean().optional(),
});
@@ -0,0 +1,100 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { TSendDocumentCreatedFromDirectTemplateEmailJobDefinition } from './send-document-created-from-direct-template-email';
export const run = async ({
payload,
}: {
payload: TSendDocumentCreatedFromDirectTemplateEmailJobDefinition;
}) => {
const { envelopeId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
url: true,
},
},
documentMeta: true,
},
});
if (!envelope) {
throw new Error('Envelope not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Recipient not found');
}
const [recipient] = envelope.recipients;
const { user: templateOwner } = envelope;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url ?? '')}/${envelope.id}`;
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: recipient.email,
recipientRole: recipient.role,
documentLink,
documentName: envelope.title,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID =
'send.document.created.from.direct.template.email';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentCreatedFromDirectTemplateEmailJobDefinition = z.infer<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Created From Direct Template Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-created-from-direct-template-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA>
>;
@@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -206,6 +207,8 @@ export const run = async ({
});
}
const sentAt = new Date();
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +216,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
sentAt,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
// Compute the first reminder time based on the envelope's effective settings.
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt,
lastReminderSentAt: null,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
};
@@ -18,6 +18,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
embedSigning: z.literal(true).optional(),
embedSigningWhiteLabel: z.literal(true).optional(),
cfr21: z.literal(true).optional(),
hipaa: z.literal(true).optional(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
@@ -9,6 +9,7 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -22,7 +23,7 @@ import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
@@ -1,5 +1,7 @@
import { Prisma, WebhookCallStatus } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { WebhookCallStatus } from '@prisma/client';
import { executeWebhookCall } from '@documenso/lib/server-only/webhooks/execute-webhook-call';
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
@@ -7,7 +9,7 @@ import type { TExecuteWebhookJobDefinition } from './execute-webhook';
export const run = async ({
payload,
io,
io: _io,
}: {
payload: TExecuteWebhookJobDefinition;
io: JobRunIO;
@@ -29,44 +31,28 @@ export const run = async ({
webhookEndpoint: url,
};
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payloadData),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
const result = await executeWebhookCall({ url, body: payloadData, secret });
await prisma.webhookCall.create({
data: {
url,
event,
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
status: result.success ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
requestBody: payloadData as Prisma.InputJsonValue,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
responseCode: result.responseCode,
responseBody: result.responseBody,
responseHeaders: result.responseHeaders,
webhookId: webhook.id,
},
});
if (!response.ok) {
throw new Error(`Webhook execution failed with status ${response.status}`);
if (!result.success) {
throw new Error(`Webhook execution failed with status ${result.responseCode}`);
}
return {
success: response.ok,
status: response.status,
success: true,
status: result.responseCode,
};
};
@@ -0,0 +1,222 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
export const run = async ({
payload,
io,
}: {
payload: TProcessSigningReminderJobDefinition;
io: JobRunIO;
}) => {
const { recipientId } = payload;
const now = new Date();
// Atomically claim this reminder by setting lastReminderSentAt and clearing
// nextReminderAt so no other sweep picks it up.
const updatedCount = await prisma.recipient.updateMany({
where: {
id: recipientId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
data: {
lastReminderSentAt: now,
nextReminderAt: null,
},
});
if (updatedCount.count === 0) {
io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
return;
}
const recipient = await prisma.recipient.findFirst({
where: { id: recipientId },
include: {
envelope: {
include: {
documentMeta: true,
user: true,
recipients: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
io.logger.warn(`Recipient ${recipientId} not found`);
return;
}
const { envelope } = recipient;
if (!envelope.documentMeta) {
io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
return;
}
// Skip if distribution method is NONE (manual link sharing, no emails).
if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
return;
}
if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailSubject = i18n._(
msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
);
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
}
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': envelope.title,
};
if (envelope.documentMeta.subject) {
emailSubject = renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
);
}
const emailMessage = envelope.documentMeta.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
},
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Compute the next reminder time (repeat interval).
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
});
}
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TProcessSigningReminderJobDefinition = z.infer<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
>;
export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
name: 'Process Signing Reminder',
version: '1.0.0',
trigger: {
name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./process-signing-reminder.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
TProcessSigningReminderJobDefinition
>;
@@ -0,0 +1,105 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentSweepJobDefinition } from './seal-document-sweep';
export const run = async ({ io }: { payload: TSealDocumentSweepJobDefinition; io: JobRunIO }) => {
const now = DateTime.now();
const fifteenMinutesAgo = now.minus({ minutes: 15 }).toJSDate();
const sixHoursAgo = now.minus({ hours: 6 }).toJSDate();
// Find all PENDING envelopes that should have been sealed but weren't.
//
// A document is ready to seal when either:
// 1. All recipients are SIGNED or have role CC (normal completion)
// 2. Any recipient has REJECTED (rejection triggers immediate seal)
//
// We only look at documents where the last action was between 15 minutes
// and 6 hours ago. The lower bound avoids racing with the normal seal-document
// job that fires on completion. The upper bound stops us from endlessly retrying
// documents that are stuck due to a deeper issue (e.g. corrupt PDF).
const unsealedEnvelopes = await kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.secondaryId'])
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.deletedAt', 'is', null)
// Ensure there is at least one recipient.
.where((eb) =>
eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')),
)
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
.where((eb) =>
eb.or([
// Case 1: All recipients are either SIGNED or CC.
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
),
),
// Case 2: Any recipient has rejected.
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
]),
)
// Exclude envelopes where a recipient signed/rejected within the last 15 minutes
// to avoid racing with the standard completion flow.
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', fifteenMinutesAgo),
),
),
)
// Exclude envelopes where all activity is older than 6 hours.
// These are likely stuck due to a deeper issue and should not be retried.
.where((eb) =>
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signedAt', '>', sixHoursAgo),
),
)
.limit(100)
.execute();
if (unsealedEnvelopes.length === 0) {
io.logger.info('No unsealed documents found');
return;
}
io.logger.info(`Found ${unsealedEnvelopes.length} unsealed documents`);
await Promise.allSettled(
unsealedEnvelopes.map(async (envelope) => {
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
io.logger.info(`Triggering seal for document ${documentId} (${envelope.id})`);
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
isResealing: true,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID = 'internal.seal-document-sweep';
const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSealDocumentSweepJobDefinition = z.infer<
typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION = {
id: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
name: 'Seal Document Sweep',
version: '1.0.0',
trigger: {
name: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./seal-document-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID,
TSealDocumentSweepJobDefinition
>;
@@ -285,18 +285,13 @@ export const run = async ({
await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({
await tx.envelopeItem.update({
where: {
id: newDocumentDataId,
},
});
await tx.documentData.update({
where: {
id: oldDocumentDataId,
envelopeId: envelope.id,
documentDataId: oldDocumentDataId,
},
data: {
data: newData.data,
documentDataId: newDocumentDataId,
},
});
}
@@ -496,11 +491,14 @@ const decorateAndSignPdf = async ({
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBytes),
});
const { documentData: newDocumentData } = await putPdfFileServerSide(
{
name: `${name}${suffix}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBytes),
},
envelopeItem.documentData.initialData,
);
return {
oldDocumentDataId: envelopeItem.documentData.id,
@@ -0,0 +1,49 @@
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
export const run = async ({
io,
}: {
payload: TSendSigningRemindersSweepJobDefinition;
io: JobRunIO;
}) => {
const now = new Date();
const recipients = await prisma.recipient.findMany({
where: {
nextReminderAt: { lte: now },
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
role: { not: RecipientRole.CC },
envelope: {
status: DocumentStatus.PENDING,
deletedAt: null,
},
},
select: { id: true },
take: 1000,
});
if (recipients.length === 0) {
io.logger.info('No recipients need signing reminders');
return;
}
io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
await Promise.allSettled(
recipients.map(async (recipient) => {
await jobs.triggerJob({
name: 'internal.process-signing-reminder',
payload: {
recipientId: recipient.id,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TSendSigningRemindersSweepJobDefinition = z.infer<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
name: 'Send Signing Reminders Sweep',
version: '1.0.0',
trigger: {
name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./send-signing-reminders-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
TSendSigningRemindersSweepJobDefinition
>;
@@ -46,6 +46,14 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io:
batch.map(async (domain) => {
const shouldReregister = domain.createdAt < reregisterCutoff;
const { isVerified } = await verifyEmailDomain(domain.id);
if (isVerified) {
io.logger.info(`Domain "${domain.domain}" is verified`);
return 'verified' as const;
}
if (shouldReregister) {
io.logger.info(
`Domain "${domain.domain}" has been pending since ${domain.createdAt.toISOString()}, attempting re-registration`,
@@ -55,9 +63,7 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io:
return 'reregistered' as const;
}
const { isVerified } = await verifyEmailDomain(domain.id);
return isVerified ? ('verified' as const) : ('pending' as const);
return 'pending' as const;
}),
);
+11 -4
View File
@@ -10,6 +10,8 @@
"universal/"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
@@ -21,6 +23,9 @@
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0",
"@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
"@documenso/assets": "*",
"@documenso/email": "*",
"@documenso/prisma": "*",
@@ -39,11 +44,13 @@
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"bullmq": "^5.71.1",
"csv-parse": "^6.1.0",
"inngest": "^3.45.1",
"inngest": "^3.54.0",
"ioredis": "^5.10.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.8",
"kysely": "0.28.16",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
@@ -55,7 +62,6 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -66,6 +72,7 @@
"devDependencies": {
"@playwright/browser-chromium": "1.56.1",
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
"@types/pg": "^8.15.6",
"vitest": "^4.0.18"
}
}
@@ -108,32 +108,29 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};

Some files were not shown because too many files have changed in this diff Show More