diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 3f1c11259..e20b94887 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { expired: null, signedAt: null, readStatus: 'OPENED', + documentDeletedAt: null, signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', role: 'SIGNER', diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 0fb592ea1..35dbaa8f1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { DocumentStatus } from '@documenso/prisma/client'; -import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { @@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail: TeamEmail | null }; }; export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { @@ -59,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro const isOwner = document.User.id === session.user.id; const isDraft = document.status === DocumentStatus.DRAFT; + const isDeleted = document.deletedAt !== null; const isComplete = document.status === DocumentStatus.COMPLETED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && document.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Duplicate - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted} + > Delete @@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( { @@ -127,6 +128,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) )} + + {document.deletedAt && Document deleted} diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index c67890dfe..aed95662b 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -15,7 +15,6 @@ import { Pencil, Share, Trash2, - XCircle, } from 'lucide-react'; import { useSession } from 'next-auth/react'; @@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { @@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr // const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && row.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -107,7 +106,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr return ( - + @@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr )} - + Edit @@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Duplicate - + {/* No point displaying this if there's no functionality. */} + {/* Void - + */} - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail)} + > - Delete + {canManageDocument ? 'Delete' : 'Hide'} Share @@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( ; showSenderColumn?: boolean; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DocumentsDataTable = ({ diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 59fd21e60..558d39558 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -2,8 +2,11 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { match } from 'ts-pattern'; + import { DocumentStatus } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = { status: DocumentStatus; documentTitle: string; teamId?: number; + canManageDocument: boolean; }; export const DeleteDocumentDialog = ({ @@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({ status, documentTitle, teamId, + canManageDocument, }: DeleteDocumentDialogProps) => { const router = useRouter(); @@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({ !isLoading && onOpenChange(value)}> - Are you sure you want to delete "{documentTitle}"? + Are you sure? - Please note that this action is irreversible. Once confirmed, your document will be - permanently deleted. + You are about to {canManageDocument ? 'delete' : 'hide'}{' '} + "{documentTitle}" - {status !== DocumentStatus.DRAFT && ( -
- -
+ {canManageDocument ? ( + + {match(status) + .with(DocumentStatus.DRAFT, () => ( + + Please note that this action is irreversible. Once confirmed, + this document will be permanently deleted. + + )) + .with(DocumentStatus.PENDING, () => ( + +

+ Please note that this action is irreversible. +

+ +

Once confirmed, the following will occur:

+ +
    +
  • Document will be permanently deleted
  • +
  • Document signing process will be cancelled
  • +
  • All inserted signatures will be voided
  • +
  • All recipients will be notified
  • +
+
+ )) + .with(DocumentStatus.COMPLETED, () => ( + +

By deleting this document, the following will occur:

+ +
    +
  • The document will be hidden from your account
  • +
  • Recipients will still retain their copy of the document
  • +
+
+ )) + .exhaustive()} +
+ ) : ( + + + Please contact support if you would like to revert this action. + + + )} + + {status !== DocumentStatus.DRAFT && canManageDocument && ( + )} -
- + - -
+
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx index 9059b8e88..84f6bfe3f 100644 --- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx @@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); - const currentTeam = team ? { id: team.id, url: team.url } : undefined; + const currentTeam = team + ? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email } + : undefined; const getStatOptions: GetStatsInput = { user, diff --git a/apps/web/src/app/(dashboard)/documents/empty-state.tsx b/apps/web/src/app/(dashboard)/documents/empty-state.tsx index b6d2f74e2..e1af23bf2 100644 --- a/apps/web/src/app/(dashboard)/documents/empty-state.tsx +++ b/apps/web/src/app/(dashboard)/documents/empty-state.tsx @@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => { })); return ( -
+
diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts index 30d6ba11f..8676d05ed 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => { await page .getByRole('textbox', { name: 'Email', exact: true }) .fill('recipient2@documenso.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); // Display advanced settings. await page.getByLabel('Show advanced settings').click(); @@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { await page.getByPlaceholder('Name').fill('Recipient 1'); await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); // Advanced settings should not be visible for non EE users. await expect(page.getByLabel('Show advanced settings')).toBeHidden(); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index c2ae0618c..07aee6a30 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2'); await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts index 3658f1bc9..32f385df5 100644 --- a/packages/app-tests/e2e/documents/delete-documents.spec.ts +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -8,6 +8,7 @@ import { import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; test.describe.configure({ mode: 'serial' }); @@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Completed' }) .getByRole('cell', { name: 'Download' }) @@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients' email: sender.email, }); - // open actions menu + // Open document action menu. await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); // delete document @@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients' }); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); - - await page.goto(`/sign/${recipient.token}`); - await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); - - await page.goto('/documents'); - await page.waitForURL('/documents'); - await apiSignout({ page }); } }); -test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ - page, -}) => { +test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => { const { sender } = await seedDeleteDocumentsTestRequirements(); await apiSignin({ @@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Draft' }) - .getByRole('cell', { name: 'Edit' }) - .getByRole('button') + .getByTestId('document-table-action-btn') .click(); // delete document @@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional await page.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => { + const { sender } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Pending' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 2); + + // Sign into the recipient account. + await apiSignout({ page }); + await apiSignin({ + page, + email: recipients[0].email, + }); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 1); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + const recipientA = recipients[0]; + const recipientB = recipients[1]; + + await apiSignin({ + page, + email: recipientA.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Hide' }).click(); + await page.getByRole('button', { name: 'Hide' }).click(); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Pending' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Hide' }).click(); + await page.getByRole('button', { name: 'Hide' }).click(); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); + await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 0); + + // Sign into the sender account. + await apiSignout({ page }); + await apiSignin({ + page, + email: sender.email, + }); + + // Check document counts for sender. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 3); + + // Sign into the other recipient account. + await apiSignout({ page }); + await apiSignin({ + page, + email: recipientB.email, + }); + + // Check document counts for other recipient. + await checkDocumentTabCount(page, 'Inbox', 1); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); }); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts new file mode 100644 index 000000000..f7e0bd391 --- /dev/null +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + await page.getByRole('tab', { name: tabName }).click(); + + if (tabName !== 'All') { + await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); + } + + if (count === 0) { + await expect(page.getByTestId('empty-document-state')).toBeVisible(); + return; + } + + await expect(page.getByRole('main')).toContainText(`Showing ${count}`); +}; diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 8f70befc8..6cea6445d 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { DocumentStatus } from '@documenso/prisma/client'; @@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; test.describe.configure({ mode: 'parallel' }); -const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { - await page.getByRole('tab', { name: tabName }).click(); - - if (tabName !== 'All') { - await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); - } - - if (count === 0) { - await expect(page.getByRole('main')).toContainText(`Nothing to do`); - return; - } - - await expect(page.getByRole('main')).toContainText(`Showing ${count}`); -}; - test('[TEAMS]: check team documents count', async ({ page }) => { const { team, teamMember2 } = await seedTeamDocuments(); @@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa await unseedTeam(team.url); }); -test('[TEAMS]: delete pending team document', async ({ page }) => { - const { team, teamMember2: currentUser } = await seedTeamDocuments(); - - await apiSignin({ - page, - email: currentUser.email, - redirectPath: `/t/${team.url}/documents?status=PENDING`, - }); - - await page.getByRole('row').getByRole('button').nth(1).click(); - - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); - await page.getByRole('button', { name: 'Delete' }).click(); - - await checkDocumentTabCount(page, 'Pending', 1); -}); - test('[TEAMS]: resend pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); @@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => { await expect(page.getByRole('status')).toContainText('Document re-sent'); }); + +test('[TEAMS]: delete draft team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=DRAFT`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Draft', 1); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete pending team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=PENDING`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Pending', 1); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete completed team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + await page.getByRole('row').getByRole('button').nth(2).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Completed', 0); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); diff --git a/packages/email/template-components/template-document-cancel.tsx b/packages/email/template-components/template-document-cancel.tsx index 885cb6c80..dff275de2 100644 --- a/packages/email/template-components/template-document-cancel.tsx +++ b/packages/email/template-components/template-document-cancel.tsx @@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
"{documentName}" + + All signatures have been voided. + + You don't need to sign it anymore. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index b0b1ad682..a097d76e9 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { prisma } from '@documenso/prisma'; +import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -27,110 +28,178 @@ export const deleteDocument = async ({ teamId, requestMetadata, }: DeleteDocumentOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + const document = await prisma.document.findUnique({ where: { id, - ...(teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }), }, include: { Recipient: true, documentMeta: true, - User: true, + team: { + select: { + members: true, + }, + }, }, }); - if (!document) { + if (!document || (teamId !== undefined && teamId !== document.teamId)) { throw new Error('Document not found'); } - const { status, User: user } = document; + const isUserOwner = document.userId === userId; + const isUserTeamMember = document.team?.members.some((member) => member.userId === userId); + const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email); - // if the document is a draft, hard-delete - if (status === DocumentStatus.DRAFT) { + if (!isUserOwner && !isUserTeamMember && !userRecipient) { + throw new Error('Not allowed'); + } + + // Handle hard or soft deleting the actual document if user has permission. + if (isUserOwner || isUserTeamMember) { + await handleDocumentOwnerDelete({ + document, + user, + requestMetadata, + }); + } + + // Continue to hide the document from the user if they are a recipient. + if (userRecipient?.documentDeletedAt === null) { + await prisma.recipient.update({ + where: { + documentId_email: { + documentId: document.id, + email: user.email, + }, + }, + data: { + documentDeletedAt: new Date().toISOString(), + }, + }); + } + + // Return partial document for API v1 response. + return { + id: document.id, + userId: document.userId, + teamId: document.teamId, + title: document.title, + status: document.status, + documentDataId: document.documentDataId, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + completedAt: document.completedAt, + }; +}; + +type HandleDocumentOwnerDeleteOptions = { + document: Document & { + Recipient: Recipient[]; + documentMeta: DocumentMeta | null; + }; + user: User; + requestMetadata?: RequestMetadata; +}; + +const handleDocumentOwnerDelete = async ({ + document, + user, + requestMetadata, +}: HandleDocumentOwnerDeleteOptions) => { + if (document.deletedAt) { + return; + } + + // Soft delete completed documents. + if (document.status === DocumentStatus.COMPLETED) { return await prisma.$transaction(async (tx) => { - // Currently redundant since deleting a document will delete the audit logs. - // However may be useful if we disassociate audit lgos and documents if required. await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, data: { - type: 'HARD', + type: 'SOFT', }, }), }); - return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); + return await tx.document.update({ + where: { + id: document.id, + }, + data: { + deletedAt: new Date().toISOString(), + }, + }); }); } - // if the document is pending, send cancellation emails to all recipients - if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { - await Promise.all( - document.Recipient.map(async (recipient) => { - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(DocumentCancelTemplate, { - documentName: document.title, - inviterName: user.name || undefined, - inviterEmail: user.email, - assetBaseUrl, - }); - - await mailer.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: 'Document Cancelled', - html: render(template), - text: render(template, { plainText: true }), - }); - }), - ); - } - - // If the document is not a draft, only soft-delete. - return await prisma.$transaction(async (tx) => { + // Hard delete draft and pending documents. + const deletedDocument = await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit logs and documents if required. await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, data: { - type: 'SOFT', + type: 'HARD', }, }), }); - return await tx.document.update({ + return await tx.document.delete({ where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), + id: document.id, + status: { + not: DocumentStatus.COMPLETED, + }, }, }); }); + + // Send cancellation emails to recipients. + await Promise.all( + document.Recipient.map(async (recipient) => { + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentCancelTemplate, { + documentName: document.title, + inviterName: user.name || undefined, + inviterEmail: user.email, + assetBaseUrl, + }); + + await mailer.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document Cancelled', + html: render(template), + text: render(template, { plainText: true }), + }); + }), + ); + + return deletedDocument; }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index f34cc4c2c..c8b06236b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -94,24 +94,65 @@ export const findDocuments = async ({ }; } - const whereClause: Prisma.DocumentWhereInput = { - ...termFilters, - ...filters, + let deletedFilter: Prisma.DocumentWhereInput = { AND: { OR: [ { - status: ExtendedDocumentStatus.COMPLETED, + userId: user.id, + deletedAt: null, }, { - status: { - not: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + documentDeletedAt: null, + }, }, - deletedAt: null, }, ], }, }; + if (team) { + deletedFilter = { + AND: { + OR: team.teamEmail + ? [ + { + teamId: team.id, + deletedAt: null, + }, + { + User: { + email: team.teamEmail.email, + }, + deletedAt: null, + }, + { + Recipient: { + some: { + email: team.teamEmail.email, + documentDeletedAt: null, + }, + }, + }, + ] + : [ + { + teamId: team.id, + deletedAt: null, + }, + ], + }, + }; + } + + const whereClause: Prisma.DocumentWhereInput = { + ...termFilters, + ...filters, + ...deletedFilter, + }; + if (period) { const daysAgo = parseInt(period.replace(/d$/, ''), 10); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index db38fa79d..1afdbcbf2 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -72,6 +72,7 @@ type GetCountsOption = { const getCounts = async ({ user, createdAt }: GetCountsOption) => { return Promise.all([ + // Owner counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { deletedAt: null, }, }), + // Not signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, createdAt, - deletedAt: null, }, }), + // Has signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, - deletedAt: null, }, { status: ExtendedDocumentStatus.COMPLETED, @@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, }, @@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, diff --git a/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql new file mode 100644 index 000000000..6bbb11cd9 --- /dev/null +++ b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3); + +-- Hard delete all PENDING documents that have been soft deleted +DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING'; + +-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null +UPDATE "Recipient" +SET "documentDeletedAt" = "Document"."deletedAt" +FROM "Document", "User" +WHERE "Recipient"."documentId" = "Document"."id" +AND "Recipient"."email" = "User"."email" +AND "Document"."deletedAt" IS NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..8971f837f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -347,23 +347,24 @@ enum RecipientRole { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int? - templateId Int? - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) - token String - expired DateTime? - signedAt DateTime? - authOptions Json? - role RecipientRole @default(SIGNER) - readStatus ReadStatus @default(NOT_OPENED) - signingStatus SigningStatus @default(NOT_SIGNED) - sendStatus SendStatus @default(NOT_SENT) - Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) - Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) - Field Field[] - Signature Signature[] + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) + token String + documentDeletedAt DateTime? + expired DateTime? + signedAt DateTime? + authOptions Json? + role RecipientRole @default(SIGNER) + readStatus ReadStatus @default(NOT_OPENED) + signingStatus SigningStatus @default(NOT_SIGNED) + sendStatus SendStatus @default(NOT_SENT) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Field Field[] + Signature Signature[] @@unique([documentId, email]) @@unique([templateId, email]) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 25169bcec..b796f4328 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -247,9 +247,7 @@ export const AddSignersFormPartial = ({ 'col-span-4': showAdvancedSettings, })} > - {!showAdvancedSettings && index === 0 && ( - Name - )} + {!showAdvancedSettings && index === 0 && Name}