Compare commits

..

17 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan b4dbe1a4e0 fix: translations 2025-02-25 12:08:02 +00:00
Ephraim Atta-Duncan 21c1a2c25a chore: wip 2025-02-25 11:47:15 +00:00
Ephraim Atta-Duncan ef66e99634 chore: wip 2025-02-25 11:37:54 +00:00
Ephraim Atta-Duncan 55dded30a7 chore: remove duplicateS 2025-02-25 11:23:35 +00:00
Ephraim Atta-Duncan a12c4a67f1 chore: wip 2025-02-25 11:12:15 +00:00
Ephraim Atta-Duncan 59de996603 chore: wip 2025-02-25 11:00:28 +00:00
Ephraim Atta-Duncan 6f930ece4e chore: wip1 2025-02-25 10:41:28 +00:00
Ephraim Atta-Duncan 87f66edd95 chore: wip 2025-02-25 10:24:25 +00:00
Ephraim Atta-Duncan 3f4c3863e7 chore: wip 2025-02-25 09:59:50 +00:00
Ephraim Atta-Duncan 70a3f7b3e9 chore: wip 2025-02-25 09:44:10 +00:00
Ephraim Atta-Duncan 633274bab1 chore: wip 2025-02-25 08:21:00 +00:00
Ephraim Atta-Duncan 2cbe14572b chore: wip 2025-02-19 16:54:48 +00:00
Ephraim Atta-Duncan 442ba9d052 chore: wip 2025-02-19 08:41:26 +00:00
Ephraim Atta-Duncan 2cf61b92fd fix: typo 2025-02-19 08:17:36 +00:00
Ephraim Atta-Duncan aedf101965 chore: minor changes 2025-02-19 01:08:56 +00:00
David Nguyen 11528090a5 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:17:47 +11:00
Ephraim Duncan 3c4863f285 chore: add asssitant role to the docs (#1638) 2025-02-17 15:42:37 +11:00
35 changed files with 522 additions and 1642 deletions
@@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}
@@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields
@@ -16,9 +16,13 @@ import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
name: string;
email: string;
signingVolume: number;
createdAt: Date;
planId: string;
userId?: number | null;
teamId?: number | null;
isTeam: boolean;
};
type LeaderboardTableProps = {
@@ -4,7 +4,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { LeaderboardTable } from './data-table-leaderboard';
import { LeaderboardTable, type SigningVolume } from './data-table-leaderboard';
import { search } from './fetch-leaderboard.actions';
type AdminLeaderboardProps = {
@@ -32,7 +32,7 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
const sortBy = searchParams.sortBy || 'signingVolume';
const sortOrder = searchParams.sortOrder || 'desc';
const { leaderboard: signingVolume, totalPages } = await search({
const { leaderboard, totalPages } = await search({
search: searchString,
page,
perPage,
@@ -40,14 +40,22 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<div className="mt-8">
<LeaderboardTable
signingVolume={signingVolume}
signingVolume={typedSigningVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
@@ -47,22 +47,50 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
+3 -2
View File
@@ -270,11 +270,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id);
return new Set(ids).size === ids.length;
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
},
{ message: 'Recipient IDs must be unique' },
{ message: 'Recipient IDs and emails must be unique' },
),
meta: z
.object({
@@ -1,304 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_FLOW]: add signature fields for unique recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await page.getByRole('button', { name: 'Continue' }).click();
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.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add an approver
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a viewer
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a CC
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for signer and approver
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
.nth(1)
.click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for approver and signers
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('**/documents');
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
});
@@ -91,191 +91,3 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: add only recipients with the same email address', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: duplicate email recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add an approver
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a viewer
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Add a CC
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: same email with different roles', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: mixed unique and duplicate recipients', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Second recipient (duplicate of first)
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Third recipient (unique)
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fourth recipient (duplicate of first)
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
await page.getByLabel('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Signer' }).click();
// Fifth recipient (unique)
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
});
@@ -1,292 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATE_FLOW]: add signature fields for unique recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Add 2 placeholder recipients.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await page.getByRole('button', { name: 'Continue' }).click();
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.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Add a placeholder recipient
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add an approver
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a viewer
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a CC
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for signer and approver
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
.nth(1)
.click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
test('[TEMPLATE_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// First placeholder recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Second placeholder recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Third placeholder recipient (unique)
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fourth placeholder recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fifth placeholder recipient (unique)
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for approver and signers
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('button', { name: 'Save Template' }).click();
await page.waitForURL('**/templates');
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
});
@@ -98,135 +98,3 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
});
test('[TEMPLATE_FLOW]: duplicate recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});
test('[TEMPLATE_FLOW]: same email different roles', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Documenso Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add an approver
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a viewer
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Add a CC
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});
test('[TEMPLATE_FLOW]: mixed recipients', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// First recipient (unique)
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('First Recipient');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Second recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
await page.getByRole('combobox').nth(1).click();
await page.getByLabel('Needs to view').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Third recipient (unique)
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
await page.getByRole('combobox').nth(2).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fourth recipient (duplicate of first)
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
await page.getByRole('combobox').nth(3).click();
await page.getByLabel('Receives copy').click();
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// Fifth recipient (unique)
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
await page.getByRole('combobox').nth(4).click();
await page.getByLabel('Needs to sign').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
});
@@ -110,13 +110,14 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('**/templates');
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -249,8 +250,9 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -351,8 +353,9 @@ test('[TEMPLATE]: should create a document from a template with custom document'
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -431,8 +434,9 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -496,8 +500,9 @@ test('[TEMPLATE]: should create a document from a template using template docume
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the template's document data
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -586,8 +591,9 @@ test('[TEMPLATE]: should persist document visibility when creating from template
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct visibility
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
@@ -610,368 +616,3 @@ test('[TEMPLATE]: should persist document visibility when creating from template
// Template should not be visible to regular member
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
});
/**
* This test verifies that we can create a document from a template with duplicate recipients
**/
test('[TEMPLATE]: should create a document from a template with duplicate recipients', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 1');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('**/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.recipients[0];
const recipientTwo = document.recipients[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
await page.getByRole('link', { name: 'Edit' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.getByText('SignatureRE').first()).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
});
/**
* This test verifies that we can create a document from a template with a mix of duplicate and unique recipients
**/
test('[TEMPLATE]: should create a document from a template with mixed duplicate and unique recipients', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_MIXED_RECIPIENTS');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 4 signers: 2 duplicates of recipient1 and 2 unique recipients
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(2).fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').nth(2).fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(3).fill('recipient3@documenso.com');
await page.getByPlaceholder('Name').nth(3).fill('Recipient 3');
// Apply require passkey for first instance of Recipient 1
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// Add signature fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 200,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 300,
y: 100,
},
});
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 3 (recipient3@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 400,
y: 100,
},
});
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('**/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/\/documents\/\d+/);
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: {
orderBy: {
email: 'asc',
},
},
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_MIXED_RECIPIENTS');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
// Check auth settings for first instance of recipient1
const firstRecipientOne = document.recipients[0];
const firstRecipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: firstRecipientOne.authOptions,
});
if (isBillingEnabled) {
expect(firstRecipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(firstRecipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
await page.getByRole('link', { name: 'Edit' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.getByText('SignatureRE').first()).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient2@documenso.com' }).click();
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
await expect(page.getByText('SignatureRE').nth(2)).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'recipient3@documenso.com' }).click();
await expect(page.getByText('SignatureRE').nth(3)).toBeVisible();
});
@@ -1,15 +1,7 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
@@ -17,85 +9,187 @@ export type GetSigningVolumeOptions = {
sortOrder?: 'asc' | 'desc';
};
export async function getSigningVolume({
export const getSigningVolume = async ({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) {
const offset = Math.max(page - 1, 0) * perPage;
}: GetSigningVolumeOptions) => {
const validPage = Math.max(1, page);
const validPerPage = Math.max(1, perPage);
const skip = (validPage - 1) * validPerPage;
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
const activeSubscriptions = await prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
},
select: {
id: true,
planId: true,
userId: true,
teamId: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: {
select: {
email: true,
},
},
createdAt: true,
},
},
},
});
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
const userSubscriptionsMap = new Map();
const teamSubscriptionsMap = new Map();
findQuery = findQuery.limit(perPage).offset(offset);
activeSubscriptions.forEach((subscription) => {
const isTeam = !!subscription.teamId;
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
if (isTeam && subscription.teamId) {
if (!teamSubscriptionsMap.has(subscription.teamId)) {
teamSubscriptionsMap.set(subscription.teamId, {
id: subscription.id,
planId: subscription.planId,
teamId: subscription.teamId,
name: subscription.team?.name || '',
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
createdAt: subscription.team?.createdAt,
isTeam: true,
subscriptionIds: [subscription.id],
});
} else {
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
existingTeam.subscriptionIds.push(subscription.id);
}
} else if (subscription.userId) {
if (!userSubscriptionsMap.has(subscription.userId)) {
userSubscriptionsMap.set(subscription.userId, {
id: subscription.id,
planId: subscription.planId,
userId: subscription.userId,
name: subscription.user?.name || '',
email: subscription.user?.email || '',
createdAt: subscription.user?.createdAt,
isTeam: false,
subscriptionIds: [subscription.id],
});
} else {
const existingUser = userSubscriptionsMap.get(subscription.userId);
existingUser.subscriptionIds.push(subscription.id);
}
}
});
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
const subscriptions = [
...Array.from(userSubscriptionsMap.values()),
...Array.from(teamSubscriptionsMap.values()),
];
const filteredSubscriptions = search
? subscriptions.filter((sub) => {
const searchLower = search.toLowerCase();
return (
sub.name?.toLowerCase().includes(searchLower) ||
sub.email?.toLowerCase().includes(searchLower)
);
})
: subscriptions;
const signingVolume = await Promise.all(
filteredSubscriptions.map(async (subscription) => {
let signingVolume = 0;
if (subscription.userId && !subscription.isTeam) {
const personalCount = await prisma.document.count({
where: {
userId: subscription.userId,
status: DocumentStatus.COMPLETED,
teamId: null,
},
});
signingVolume += personalCount;
const userTeams = await prisma.teamMember.findMany({
where: {
userId: subscription.userId,
},
select: {
teamId: true,
},
});
if (userTeams.length > 0) {
const teamIds = userTeams.map((team) => team.teamId);
const teamCount = await prisma.document.count({
where: {
teamId: {
in: teamIds,
},
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
}
if (subscription.teamId) {
const teamCount = await prisma.document.count({
where: {
teamId: subscription.teamId,
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
return {
...subscription,
signingVolume,
};
}),
);
const sortedResults = [...signingVolume].sort((a, b) => {
if (sortBy === 'name') {
return sortOrder === 'asc'
? (a.name || '').localeCompare(b.name || '')
: (b.name || '').localeCompare(a.name || '');
}
if (sortBy === 'createdAt') {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
}
return sortOrder === 'asc'
? a.signingVolume - b.signingVolume
: b.signingVolume - a.signingVolume;
});
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
const totalPages = Math.ceil(sortedResults.length / validPerPage);
return {
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
leaderboard: paginatedResults,
totalPages,
};
}
};
@@ -72,22 +72,6 @@ export const setFieldsForDocument = async ({
});
}
// Check that every signer has a signature field
const signers = document.recipients.filter((recipient) => recipient.role === 'SIGNER');
const hasEverySignerSignature = signers.every((signer) =>
fields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.recipientId === signer.id,
),
);
if (!hasEverySignerSignature) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Every signer must have at least one signature field',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -110,7 +94,9 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
const recipient = document.recipients.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
@@ -250,8 +236,10 @@ export const setFieldsForDocument = async ({
},
recipient: {
connect: {
documentId,
id: field.recipientId,
documentId_email: {
documentId,
email: fieldSignerEmail,
},
},
},
},
@@ -352,7 +340,6 @@ type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
@@ -22,7 +22,6 @@ export type SetFieldsForTemplateOptions = {
fields: {
id?: number | null;
type: FieldType;
signerId: number;
signerEmail: string;
pageNumber: number;
pageX: number;
@@ -58,29 +57,12 @@ export const setFieldsForTemplate = async ({
teamId: null,
}),
},
include: {
recipients: true,
},
});
if (!template) {
throw new Error('Template not found');
}
// Check that every signer has a signature field
const signers = template.recipients.filter((recipient) => recipient.role === 'SIGNER');
const hasEverySignerSignature = signers.every((signer) =>
fields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerId === signer.id,
),
);
if (!hasEverySignerSignature) {
throw new Error('Every signer must have at least one signature field');
}
const existingFields = await prisma.field.findMany({
where: {
templateId,
@@ -198,8 +180,10 @@ export const setFieldsForTemplate = async ({
},
recipient: {
connect: {
templateId,
id: field.signerId,
templateId_email: {
templateId,
email: field.signerEmail.toLowerCase(),
},
},
},
},
@@ -125,12 +125,16 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
if (
@@ -103,7 +103,10 @@ export const setTemplateRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
if (template.directLink !== null) {
@@ -130,10 +133,14 @@ export const setTemplateRecipients = async ({
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return { ...recipient, _persisted: existing };
return {
...recipient,
_persisted: existing,
};
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
@@ -141,8 +141,10 @@ export const createDocumentFromTemplateLegacy = async ({
return await prisma.recipient.upsert({
where: {
documentId: document.id,
id: existingRecipient?.id,
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
@@ -256,21 +256,10 @@ export const createDocumentFromTemplate = async ({
},
});
const recipientMapping = new Map<number, number>();
template.recipients.forEach((templateRecipient, index) => {
const documentRecipient = document.recipients[index];
if (documentRecipient) {
recipientMapping.set(templateRecipient.id, documentRecipient.id);
}
});
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
finalRecipients.forEach(({ templateRecipientId, fields }) => {
const documentRecipientId = recipientMapping.get(templateRecipientId);
const recipient = document.recipients.find((r) => r.id === documentRecipientId);
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email);
if (!recipient) {
throw new Error('Recipient not found.');
@@ -1,2 +0,0 @@
-- DropIndex
DROP INDEX "Recipient_documentId_email_key";
@@ -1,5 +0,0 @@
-- DropIndex
DROP INDEX "Recipient_templateId_email_key";
-- CreateIndex
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
@@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;
+14 -6
View File
@@ -270,18 +270,25 @@ model Account {
scope String?
id_token String? @db.Text
session_state String?
password String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
id String @id @default(cuid())
sessionToken String @unique
userId Int
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DocumentStatus {
@@ -443,10 +450,11 @@ model Recipient {
fields Field[]
signatures Signature[]
@@unique([documentId, email])
@@unique([templateId, email])
@@index([documentId])
@@index([templateId])
@@index([token])
@@index([email])
}
enum FieldType {
@@ -192,6 +192,14 @@ export const ZCreateDocumentV2RequestSchema = z.object({
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(),
meta: z
.object({
+1 -3
View File
@@ -232,9 +232,8 @@ export const fieldRouter = router({
teamId,
fields: fields.map((field) => ({
id: field.nativeId,
recipientId: field.recipientId,
type: field.type,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
@@ -430,7 +429,6 @@ export const fieldRouter = router({
teamId,
fields: fields.map((field) => ({
id: field.nativeId,
signerId: field.recipientId,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
+5 -9
View File
@@ -112,15 +112,13 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
id: z.number().optional(),
type: z.nativeEnum(FieldType),
recipientId: z.number(),
signerEmail: z.string().min(1),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
pageWidth: ZFieldWidthSchema,
pageHeight: ZFieldHeightSchema,
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
@@ -138,8 +136,6 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number().min(1),
signerId: z.number().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
+90 -30
View File
@@ -49,7 +49,16 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema),
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZCreateDocumentRecipientsResponseSchema = z.object({
@@ -65,7 +74,18 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema),
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email?.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
@@ -76,19 +96,29 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
});
export const ZSetDocumentRecipientsRequestSchema = z
.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})
.refine(
(schema) => {
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(),
@@ -103,7 +133,16 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema),
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZCreateTemplateRecipientsResponseSchema = z.object({
@@ -119,7 +158,18 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema),
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
});
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
@@ -130,19 +180,29 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
});
export const ZSetTemplateRecipientsRequestSchema = z
.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})
.refine(
(schema) => {
const emails = schema.recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(),
@@ -51,7 +51,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
name: z.string().optional(),
}),
)
.describe('The information of the recipients to create the document with.'),
.describe('The information of the recipients to create the document with.')
.refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
distributeDocument: z
.boolean()
.describe('Whether to create the document as pending and distribute it to recipients.')
@@ -92,7 +92,6 @@ export type FieldFormType = {
pageY: number;
pageWidth: number;
pageHeight: number;
recipientId: number;
signerEmail: string;
fieldMeta?: FieldMeta;
};
@@ -144,7 +143,6 @@ export const AddFieldsFormPartial = ({
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
recipientId: field.recipientId,
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
@@ -350,7 +348,6 @@ export const AddFieldsFormPartial = ({
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
recipientId: selectedSigner.id,
signerEmail: selectedSigner.email,
fieldMeta: undefined,
};
@@ -444,7 +441,6 @@ export const AddFieldsFormPartial = ({
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
@@ -453,7 +449,7 @@ export const AddFieldsFormPartial = ({
append(newField);
}
},
[append, lastActiveField, selectedSigner?.id, selectedSigner?.email, toast],
[append, lastActiveField, selectedSigner?.email, toast],
);
const onFieldPaste = useCallback(
@@ -466,15 +462,13 @@ export const AddFieldsFormPartial = ({
append({
...copiedField,
formId: nanoid(12),
recipientId: selectedSigner?.id ?? copiedField.recipientId,
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
}
},
[append, fieldClipboard, selectedSigner?.id, selectedSigner?.email],
[append, fieldClipboard, selectedSigner?.email],
);
useEffect(() => {
@@ -573,7 +567,7 @@ export const AddFieldsFormPartial = ({
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.recipientId === signer.id,
field.signerEmail === signer.email,
),
);
@@ -643,7 +637,7 @@ export const AddFieldsFormPartial = ({
{isDocumentPdfLoaded &&
localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
const hasFieldError =
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
emptyRadioFields.find((f) => f.formId === field.formId) ||
@@ -655,7 +649,7 @@ export const AddFieldsFormPartial = ({
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={
selectedSigner?.id !== field.recipientId ||
selectedSigner?.email !== field.signerEmail ||
!canRecipientBeModified(selectedSigner, fields)
}
minHeight={MIN_HEIGHT_PX}
@@ -10,7 +10,6 @@ export const ZAddFieldsFormSchema = z.object({
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
@@ -6,22 +6,34 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
});
export const ZAddSignersFormSchema = z
.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
);
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
+13 -8
View File
@@ -8,14 +8,19 @@ import { FieldType } from '@documenso/prisma/client';
export const ZDocumentFlowFormSchema = z.object({
title: z.string().min(1),
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
),
signers: z
.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
fields: z.array(
z.object({
@@ -60,7 +60,6 @@ import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import { Checkbox } from '../checkbox';
import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { MissingSignatureFieldDialog } from '../document-flow/missing-signature-field-dialog';
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
import { useStep } from '../stepper';
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
@@ -111,7 +110,6 @@ export const AddTemplateFieldsFormPartial = ({
const [fieldClipboard, setFieldClipboard] = useState<
TAddTemplateFieldsFormSchema['fields'][0] | null
>(null);
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const form = useForm<TAddTemplateFieldsFormSchema>({
defaultValues: {
@@ -124,7 +122,6 @@ export const AddTemplateFieldsFormPartial = ({
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
recipientId: field.recipientId ?? -1,
signerId: field.recipientId ?? -1,
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
@@ -180,8 +177,6 @@ export const AddTemplateFieldsFormPartial = ({
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
recipientId:
selectedSigner?.id || lastActiveField.recipientId || lastActiveField.signerId || 0,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
@@ -206,29 +201,19 @@ export const AddTemplateFieldsFormPartial = ({
event.preventDefault();
const copiedField = structuredClone(fieldClipboard);
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
append({
...copiedField,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
signerId: selectedSigner?.id ?? copiedField.signerId,
recipientId: selectedSigner?.id || copiedField.recipientId || copiedField.signerId || 0,
signerToken: selectedSigner?.token ?? copiedField.signerToken,
signerIndex: signerIndex >= 0 ? signerIndex : 0,
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
}
},
[
append,
fieldClipboard,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
recipients,
],
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
);
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@@ -334,8 +319,6 @@ export const AddTemplateFieldsFormPartial = ({
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner.id);
append({
formId: nanoid(12),
type: selectedField,
@@ -346,17 +329,14 @@ export const AddTemplateFieldsFormPartial = ({
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
signerId: selectedSigner.id,
recipientId:
selectedSigner.id || lastActiveField?.recipientId || lastActiveField?.signerId || 0,
signerToken: selectedSigner.token ?? '',
signerIndex: signerIndex >= 0 ? signerIndex : 0,
fieldMeta: undefined,
});
setIsFieldWithinBounds(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner, getPage, recipients],
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
);
const onFieldResize = useCallback(
@@ -519,23 +499,6 @@ export const AddTemplateFieldsFormPartial = ({
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
};
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.recipientId === signer.id,
),
);
if (!everySignerHasSignature) {
setIsMissingSignatureDialogVisible(true);
return;
}
void onFormSubmit();
};
return (
<>
{showAdvancedSettings && currentField ? (
@@ -583,15 +546,14 @@ export const AddTemplateFieldsFormPartial = ({
)}
{localFields.map((field, index) => {
const recipientIndex =
field.signerIndex ?? recipients.findIndex((r) => r.id === field.signerId);
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
return (
<FieldItem
key={index}
recipientIndex={recipientIndex >= 0 ? recipientIndex : 0}
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
field={field}
disabled={selectedSigner?.id !== field.signerId}
disabled={selectedSigner?.email !== field.signerEmail}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
@@ -1031,14 +993,9 @@ export const AddTemplateFieldsFormPartial = ({
previousStep();
remove();
}}
onGoNextClick={handleGoNextClick}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
</div>
</DocumentFlowFormContainerContent>
</>
@@ -11,14 +11,12 @@ export const ZAddTemplateFieldsFormSchema = z.object({
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
signerToken: z.string(),
signerId: z.number(),
recipientId: z.number(),
signerId: z.number().optional(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
signerIndex: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
}),
),
@@ -92,7 +92,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
actionAuth: undefined,
...generateRecipientPlaceholder(1),
signingOrder: 1,
signerIndex: 0,
},
];
}
@@ -105,7 +104,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: recipient.role,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
signingOrder: recipient.signingOrder ?? index + 1,
signerIndex: index,
}));
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
@@ -176,35 +174,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
});
const onAddPlaceholderSelfRecipient = () => {
const currentSigners = form.getValues('signers');
const nextSignerIndex = currentSigners.length;
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
signingOrder:
currentSigners.length > 0
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
: 1,
signerIndex: nextSignerIndex,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
const onAddPlaceholderRecipient = () => {
const currentSigners = form.getValues('signers');
const nextSignerIndex = currentSigners.length;
appendSigner({
formId: nanoid(12),
role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder:
currentSigners.length > 0
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
: 1,
signerIndex: nextSignerIndex,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
setPlaceholderRecipientCount((count) => count + 1);
@@ -5,21 +5,32 @@ import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/client';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
signerIndex: z.number().min(0),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
});
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
signers: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
);
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
typeof ZAddTemplatePlacholderRecipientsFormSchema