mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
fix: update document deletion logic (#1100)
This commit is contained in:
@ -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',
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 = ({
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
17
packages/app-tests/e2e/fixtures/documents.ts
Normal 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}`);
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user