mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
Merge branch 'main' into feat/public-completed-document-access
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }>;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+100
@@ -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,
|
||||
});
|
||||
};
|
||||
+33
@@ -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;
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user