fix: update document deletion logic (#1100)

This commit is contained in:
David Nguyen
2024-04-19 17:37:38 +07:00
committed by GitHub
parent 6e09a4700b
commit bd40e63392
20 changed files with 651 additions and 214 deletions

View File

@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
expired: null, expired: null,
signedAt: null, signedAt: null,
readStatus: 'OPENED', readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER', role: 'SIGNER',

View File

@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; 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 { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { 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 isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( <DeleteDocumentDialog
<DeleteDocumentDialog id={document.id}
id={document.id} status={document.status}
status={document.status} documentTitle={document.title}
documentTitle={document.title} open={isDeleteDialogOpen}
open={isDeleteDialogOpen} canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={document.id} id={document.id}

View File

@ -12,7 +12,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client'; import type { Team, TeamEmail } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -34,7 +35,7 @@ export type DocumentPageViewProps = {
params: { params: {
id: string; id: string;
}; };
team?: Team; team?: Team & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -127,6 +128,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
)} )}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div> </div>
</div> </div>

View File

@ -15,7 +15,6 @@ import {
Pencil, Pencil,
Share, Share,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -107,7 +106,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger data-testid="document-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" /> <MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild> <DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}> <Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled> {/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" /> <XCircle className="mr-2 h-4 w-4" />
Void Void
</DropdownMenuItem> </DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete {canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( <DeleteDocumentDialog
<DeleteDocumentDialog id={row.id}
id={row.id} status={row.status}
status={row.status} documentTitle={row.title}
documentTitle={row.title} open={isDeleteDialogOpen}
open={isDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} teamId={team?.id}
teamId={team?.id} canManageDocument={canManageDocument}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
} }
>; >;
showSenderColumn?: boolean; showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DocumentsDataTable = ({ export const DocumentsDataTable = ({

View File

@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus; status: DocumentStatus;
documentTitle: string; documentTitle: string;
teamId?: number; teamId?: number;
canManageDocument: boolean;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
status, status,
documentTitle, documentTitle,
teamId, teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => { }: DeleteDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
<DialogDescription> <DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
permanently deleted. <strong>"{documentTitle}"</strong>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{status !== DocumentStatus.DRAFT && ( {canManageDocument ? (
<div className="mt-4"> <Alert variant="warning" className="-mt-1">
<Input {match(status)
type="text" .with(DocumentStatus.DRAFT, () => (
value={inputValue} <AlertDescription>
onChange={onInputChange} Please note that this action is <strong>irreversible</strong>. Once confirmed,
placeholder="Type 'delete' to confirm" this document will be permanently deleted.
/> </AlertDescription>
</div> ))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
)} )}
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Button Cancel
type="button" </Button>
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button <Button
type="button" type="button"
loading={isLoading} loading={isLoading}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled} disabled={!isDeleteEnabled && canManageDocument}
variant="destructive" variant="destructive"
className="flex-1" >
> {canManageDocument ? 'Delete' : 'Hide'}
Delete </Button>
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); 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 = { const getStatOptions: GetStatsInput = {
user, user,

View File

@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
})); }));
return ( return (
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"> <div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">

View File

@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
await page await page
.getByRole('textbox', { name: 'Email', exact: true }) .getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com'); .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. // Display advanced settings.
await page.getByLabel('Show advanced settings').click(); 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.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); 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: '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. // Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await expect(page.getByLabel('Show advanced settings')).toBeHidden();

View File

@ -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.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); 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: '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(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -8,6 +8,7 @@ import {
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page await page
.locator('tr', { hasText: 'Document 1 - Completed' }) .locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' }) .getByRole('cell', { name: 'Download' })
@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document // 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 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 }); await apiSignout({ page });
} }
}); });
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements(); const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({ await apiSignin({
@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page await page
.locator('tr', { hasText: 'Document 1 - Draft' }) .locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' }) .getByTestId('document-table-action-btn')
.getByRole('button')
.click(); .click();
// delete document // 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 page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); 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);
}); });

View File

@ -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}`);
};

View File

@ -1,4 +1,3 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client'; 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 { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' }); 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 }) => { test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments(); 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); 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 }) => { test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments(); 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'); 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);
});

View File

@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400">
All signatures have been voided.
</Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore. You don't need to sign it anymore.
</Text> </Text>

View File

@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -27,110 +28,178 @@ export const deleteDocument = async ({
teamId, teamId,
requestMetadata, requestMetadata,
}: DeleteDocumentOptions) => { }: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id, id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,
documentMeta: true, documentMeta: true,
User: true, team: {
select: {
members: true,
},
},
}, },
}); });
if (!document) { if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found'); 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 (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (status === DocumentStatus.DRAFT) { 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) => { 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({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: id, documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { 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 // Hard delete draft and pending documents.
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { const deletedDocument = await prisma.$transaction(async (tx) => {
await Promise.all( // Currently redundant since deleting a document will delete the audit logs.
document.Recipient.map(async (recipient) => { // However may be useful if we disassociate audit logs and documents if required.
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) => {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: id, documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { data: {
type: 'SOFT', type: 'HARD',
}, },
}), }),
}); });
return await tx.document.update({ return await tx.document.delete({
where: { where: {
id, id: document.id,
}, status: {
data: { not: DocumentStatus.COMPLETED,
deletedAt: new Date().toISOString(), },
}, },
}); });
}); });
// 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;
}; };

View File

@ -94,24 +94,65 @@ export const findDocuments = async ({
}; };
} }
const whereClause: Prisma.DocumentWhereInput = { let deletedFilter: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
AND: { AND: {
OR: [ OR: [
{ {
status: ExtendedDocumentStatus.COMPLETED, userId: user.id,
deletedAt: null,
}, },
{ {
status: { Recipient: {
not: ExtendedDocumentStatus.COMPLETED, 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) { if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10); const daysAgo = parseInt(period.replace(/d$/, ''), 10);

View File

@ -72,6 +72,7 @@ type GetCountsOption = {
const getCounts = async ({ user, createdAt }: GetCountsOption) => { const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([ return Promise.all([
// Owner counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
deletedAt: null, deletedAt: null,
}, },
}), }),
// Not signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
createdAt, createdAt,
deletedAt: null,
}, },
}), }),
// Has signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
}, },
@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,

View File

@ -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;

View File

@ -347,23 +347,24 @@ enum RecipientRole {
} }
model Recipient { model Recipient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
documentId Int? documentId Int?
templateId Int? templateId Int?
email String @db.VarChar(255) email String @db.VarChar(255)
name String @default("") @db.VarChar(255) name String @default("") @db.VarChar(255)
token String token String
expired DateTime? documentDeletedAt DateTime?
signedAt DateTime? expired DateTime?
authOptions Json? signedAt DateTime?
role RecipientRole @default(SIGNER) authOptions Json?
readStatus ReadStatus @default(NOT_OPENED) role RecipientRole @default(SIGNER)
signingStatus SigningStatus @default(NOT_SIGNED) readStatus ReadStatus @default(NOT_OPENED)
sendStatus SendStatus @default(NOT_SENT) signingStatus SigningStatus @default(NOT_SIGNED)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) sendStatus SendStatus @default(NOT_SENT)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[] Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Signature Signature[] Field Field[]
Signature Signature[]
@@unique([documentId, email]) @@unique([documentId, email])
@@unique([templateId, email]) @@unique([templateId, email])

View File

@ -247,9 +247,7 @@ export const AddSignersFormPartial = ({
'col-span-4': showAdvancedSettings, 'col-span-4': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormLabel required>Name</FormLabel>
)}
<FormControl> <FormControl>
<Input <Input