mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: add cancellable document status (#2992)
Adds a CANCELLED envelope status that privileged members (owner or team admin/manager) can move a pending document into. Sending recipient notifications via a background job while retaining the document in the dashboard as proof of distribution. Includes a dedicated Cancelled tab, single and bulk cancel actions, the ENVELOPE_CANCELLED mutability guard, and e2e coverage for permissions and visibility.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type EnvelopeCancelDialogProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
trigger?: React.ReactNode;
|
||||
onCancel?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EnvelopeCancelDialog = ({ id, title, trigger, onCancel }: EnvelopeCancelDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { mutateAsync: cancelEnvelope, isPending } = trpcReact.envelope.cancel.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: t`Document cancelled`,
|
||||
description: t`"${title}" has been successfully cancelled`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
|
||||
await onCancel?.();
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be cancelled at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to cancel <strong>"{title}"</strong>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document signing process will be stopped</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will be notified that the document was cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>The document will remain in your dashboard marked as Cancelled</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cancel-reason">
|
||||
<Trans>Reason (optional)</Trans>
|
||||
</Label>
|
||||
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t`Add an optional reason for cancelling this document`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void cancelEnvelope({ envelopeId: id, reason: reason || undefined })}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Cancel document</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +166,7 @@ export const EnvelopeDeleteDialog = ({
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type EnvelopesBulkCancelDialogProps = {
|
||||
envelopeIds: string[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkCancelDialog = ({
|
||||
envelopeIds,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkCancelDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: bulkCancelEnvelopes, isPending } = trpc.envelope.bulk.cancel.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
toast({
|
||||
title: t`Documents partially cancelled`,
|
||||
description: t`${plural(result.cancelledCount, {
|
||||
one: '# document cancelled.',
|
||||
other: '# documents cancelled.',
|
||||
})} ${plural(result.failedIds.length, {
|
||||
one: '# document could not be cancelled.',
|
||||
other: '# documents could not be cancelled.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Documents cancelled`,
|
||||
description: plural(result.cancelledCount, {
|
||||
one: '# document has been cancelled.',
|
||||
other: '# documents have been cancelled.',
|
||||
}),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while cancelling the documents.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Cancel Documents</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to cancel the selected document."
|
||||
other="You are about to cancel # documents."
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Only pending documents you have permission to manage will be cancelled.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document signing process will be stopped</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will be notified that the document was cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>The documents will remain in your dashboard marked as Cancelled</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bulk-cancel-reason">
|
||||
<Trans>Reason (optional)</Trans>
|
||||
</Label>
|
||||
|
||||
<Textarea
|
||||
id="bulk-cancel-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t`Add an optional reason for cancelling these documents`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void bulkCancelEnvelopes({ envelopeIds, reason: reason || undefined });
|
||||
}}
|
||||
loading={isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Cancel documents</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: msg`Cancelled`,
|
||||
labelExtended: msg`Document cancelled`,
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
INBOX: {
|
||||
label: msg`Inbox`,
|
||||
labelExtended: msg`Document inbox`,
|
||||
|
||||
@@ -148,6 +148,11 @@ export default function EnvelopeEditorHeader() {
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.CANCELLED, () => (
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
<Trans>Cancelled</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{autosaveError && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/documen
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { formatDocumentsPath, isMemberManagerOrAbove } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
@@ -30,11 +30,13 @@ import {
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
@@ -74,6 +76,12 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
// Cancelling a document is restricted server-side to the document owner or a
|
||||
// privileged team member (ADMIN/MANAGER). Mirror that here so plain MEMBERs
|
||||
// don't see a Cancel action that would fail on the server.
|
||||
const isPrivilegedTeamMember = isMemberManagerOrAbove(team.currentTeamRole);
|
||||
const canCancelDocument = isOwner || isPrivilegedTeamMember;
|
||||
|
||||
const { canTitleBeChanged } = getEnvelopeItemPermissions(
|
||||
{
|
||||
completedAt: row.completedAt,
|
||||
@@ -184,11 +192,23 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
{canCancelDocument && isPending && (
|
||||
<EnvelopeCancelDialog
|
||||
id={row.envelopeId}
|
||||
title={row.title}
|
||||
onCancel={async () => {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
}}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type DocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
|
||||
@@ -24,6 +24,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
||||
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () => ({
|
||||
title: msg`Nothing cancelled`,
|
||||
message: msg`There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed.`,
|
||||
icon: XCircle,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: msg`We're all empty`,
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
|
||||
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
|
||||
|
||||
export type EnvelopesTableBulkActionBarProps = {
|
||||
selectedCount: number;
|
||||
onMoveClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onCancelClick?: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
@@ -13,6 +14,7 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
selectedCount,
|
||||
onMoveClick,
|
||||
onDeleteClick,
|
||||
onCancelClick,
|
||||
onClearSelection,
|
||||
}: EnvelopesTableBulkActionBarProps) => {
|
||||
const { t } = useLingui();
|
||||
@@ -34,6 +36,13 @@ export const EnvelopesTableBulkActionBar = ({
|
||||
<Trans>Move to Folder</Trans>
|
||||
</Button>
|
||||
|
||||
{onCancelClick && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
|
||||
@@ -224,6 +224,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
{match(envelope.status)
|
||||
.with(DocumentStatus.COMPLETED, () => <Trans>This document has been signed by all recipients</Trans>)
|
||||
.with(DocumentStatus.REJECTED, () => <Trans>This document has been rejected by a recipient</Trans>)
|
||||
.with(DocumentStatus.CANCELLED, () => <Trans>This document has been cancelled</Trans>)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||
))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
@@ -61,6 +62,7 @@ export default function DocumentsPage() {
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
return Object.keys(rowSelection).filter((id) => rowSelection[id]);
|
||||
@@ -71,6 +73,7 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
@@ -150,6 +153,7 @@ export default function DocumentsPage() {
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
@@ -227,6 +231,7 @@ export default function DocumentsPage() {
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
@@ -246,6 +251,13 @@ export default function DocumentsPage() {
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkCancelDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
open={isBulkCancelDialogOpen}
|
||||
onOpenChange={setIsBulkCancelDialogOpen}
|
||||
onSuccess={() => setRowSelection({})}
|
||||
/>
|
||||
</div>
|
||||
</EnvelopeDropZoneWrapper>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
@@ -22,6 +22,9 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const presignToken = searchParams.get('token') ?? undefined;
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||
@@ -57,11 +60,14 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
const documentData = await putPdfFile({
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
});
|
||||
const documentData = await putPdfFile(
|
||||
{
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
},
|
||||
{ presignToken },
|
||||
);
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const documentExternalId = externalId || configuration.meta.externalId;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
@@ -20,6 +20,9 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const presignToken = searchParams.get('token') ?? undefined;
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||
@@ -55,11 +58,14 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
const documentData = await putPdfFile({
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
});
|
||||
const documentData = await putPdfFile(
|
||||
{
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
},
|
||||
{ presignToken },
|
||||
);
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const metaWithExternalId = {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
@@ -26,6 +28,29 @@ type DocumentDataInput = {
|
||||
initialData: string;
|
||||
};
|
||||
|
||||
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
if (session.user?.id) {
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
const authorizationHeader = c.req.header('authorization');
|
||||
|
||||
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
|
||||
|
||||
const queryToken = c.req.query('token');
|
||||
const presignToken = bearerToken || queryToken;
|
||||
|
||||
if (!presignToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
|
||||
|
||||
return verifiedToken?.userId ?? null;
|
||||
};
|
||||
|
||||
type EnvelopeForPendingDownload = {
|
||||
id: string;
|
||||
status: DocumentStatus;
|
||||
|
||||
@@ -10,8 +10,9 @@ import type { Prisma } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||
import {
|
||||
isAllowedUploadContentType,
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||
@@ -31,6 +32,12 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
*/
|
||||
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
|
||||
try {
|
||||
const userId = await resolveFileUploadUserId(c);
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { file } = c.req.valid('form');
|
||||
|
||||
if (!file) {
|
||||
@@ -55,10 +62,20 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
}
|
||||
})
|
||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
||||
const userId = await resolveFileUploadUserId(c);
|
||||
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { fileName, contentType } = c.req.valid('json');
|
||||
|
||||
if (!isAllowedUploadContentType(contentType)) {
|
||||
return c.json({ error: 'Unsupported content type' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType);
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
|
||||
|
||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,6 +13,14 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||
|
||||
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
|
||||
|
||||
export const isAllowedUploadContentType = (contentType: string): boolean => {
|
||||
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
|
||||
|
||||
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
|
||||
};
|
||||
|
||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentDataType, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { tsr } from '@ts-rest/serverless/fetch';
|
||||
import { match } from 'ts-pattern';
|
||||
import '@documenso/lib/constants/time-zones';
|
||||
@@ -240,7 +240,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
if (!downloadOriginalDocument && !isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed, so its data is the unsigned original.
|
||||
// Treat it as not-completed here so a "signed" version is never served for it.
|
||||
// REJECTED and COMPLETED keep their prior behavior.
|
||||
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
|
||||
|
||||
if (!downloadOriginalDocument && !hasSignedArtifact) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
|
||||
const { token } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
test.describe('Envelope cancel endpoint authorization', () => {
|
||||
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${outsiderToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// Outsiders must not be able to determine whether the envelope exists.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The document must be untouched.
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient, team: recipientTeam } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${recipientToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// A recipient is not a member of the document's team, so they must not be
|
||||
// able to determine whether it exists via this endpoint.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
|
||||
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
|
||||
// restriction is covered through the UI tests in cancel-documents.spec.ts
|
||||
// rather than at the API layer.
|
||||
|
||||
test('allows the document owner to cancel a pending document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, completedAt: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(documentInDb.completedAt).not.toBeNull();
|
||||
expect(documentInDb.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.MANAGER,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${managerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('rejects cancelling a draft document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const document = await seedDraftDocument(owner, team.id, []);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
test('rejects cancelling a completed document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedCompletedDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
|
||||
|
||||
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(firstRes.status()).toBe(200);
|
||||
|
||||
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createPresignTokenForUser = async (userId: number, teamId: number) => {
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName: 'file-upload-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
|
||||
|
||||
return presignToken;
|
||||
};
|
||||
|
||||
const buildPdfFormData = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
test.describe('File upload endpoint authorization', () => {
|
||||
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer not-a-real-token',
|
||||
},
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${presignToken}`,
|
||||
},
|
||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
||||
});
|
||||
|
||||
// Authenticated, but the content type is not on the allow-list.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
headers: { Authorization: `Bearer ${presignToken}` },
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
@@ -250,3 +253,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const [pending1, pending2] = await Promise.all([
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
|
||||
}),
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
|
||||
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
|
||||
// Both documents are now cancelled in the database.
|
||||
for (const document of [pending1, pending2]) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Pending' },
|
||||
});
|
||||
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Draft' },
|
||||
});
|
||||
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Completed' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText('3 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
|
||||
// The draft and completed documents are untouched.
|
||||
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: draft.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: completed.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: ownerDocument.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedCancelDocumentsTestRequirements = async () => {
|
||||
const [sender, recipientA, recipientB] = await Promise.all([
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
]);
|
||||
|
||||
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
|
||||
createDocumentOptions: { title: 'Document 1 - Pending' },
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
recipients: [recipientA, recipientB],
|
||||
pendingDocument,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
|
||||
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
|
||||
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
if (reason) {
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel document' }).click();
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
|
||||
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Cancelled', 1);
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.completedAt).not.toBeNull();
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
|
||||
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still be able to see the document as a record of distribution.
|
||||
for (const recipient of recipients) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.user.email,
|
||||
redirectPath: `/t/${recipient.team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// The envelope is soft deleted, not hard deleted.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).not.toBeNull();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still retain the document after the owner deletes it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipients[0].user.email,
|
||||
redirectPath: `/t/${recipients[0].team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Visibility: a cancelled document must respect team document visibility ───
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Only Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
// Also confirm it doesn't leak via the ALL tab.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must NOT see an ADMIN-visibility document either.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The ADMIN must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'MANAGER_AND_ABOVE',
|
||||
title: 'Cancelled Manager Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// A MEMBER who is also a recipient on an ADMIN-visibility document.
|
||||
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [memberRecipient], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Doc With Recipient',
|
||||
},
|
||||
});
|
||||
|
||||
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
|
||||
// must still see it (proof of distribution), matching completed-document behaviour.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberRecipient.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── UI gating: only privileged members see the Cancel action ────────────────
|
||||
|
||||
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Member Gating Pending Document' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// The dropdown must render (Edit is always there) but Cancel must be absent.
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
|
||||
[DocumentStatus.REJECTED]: {
|
||||
description: msg`Rejected`,
|
||||
},
|
||||
[DocumentStatus.CANCELLED]: {
|
||||
description: msg`Cancelled`,
|
||||
},
|
||||
[DocumentStatus.DRAFT]: {
|
||||
description: msg`Draft`,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum AppErrorCode {
|
||||
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
|
||||
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
|
||||
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
|
||||
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
|
||||
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
|
||||
/**
|
||||
* Authoring mutation rejected because the envelope is an AES/QES envelope
|
||||
@@ -80,6 +81,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
@@ -286,6 +288,7 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_DRAFT,
|
||||
AppErrorCode.ENVELOPE_COMPLETED,
|
||||
AppErrorCode.ENVELOPE_REJECTED,
|
||||
AppErrorCode.ENVELOPE_CANCELLED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isMemberManagerOrAbove } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CancelDocumentOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
||||
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserOwner = envelope.userId === userId;
|
||||
|
||||
const teamRole = await getMemberRoles({
|
||||
teamId: envelope.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
.then((roles) => roles.teamRole)
|
||||
.catch(() => null);
|
||||
|
||||
const isUserTeamMember = teamRole !== null;
|
||||
|
||||
// Callers with no relationship to the document must not be able to determine
|
||||
// whether it exists, so respond as if it was not found.
|
||||
if (!isUserOwner && !isUserTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
||||
|
||||
// The document is visible to the caller, but cancelling requires elevated permissions.
|
||||
if (!isUserOwner && !isPrivilegedTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Only pending documents can be cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Send cancellation emails to recipients via the resilient background job.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger the webhook with the updated (cancelled) envelope payload.
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(
|
||||
mapEnvelopeToWebhookDocumentPayload({
|
||||
...envelope,
|
||||
status: updatedEnvelope.status,
|
||||
completedAt: updatedEnvelope.completedAt,
|
||||
}),
|
||||
),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedEnvelope;
|
||||
};
|
||||
@@ -220,7 +220,11 @@ export const findDocuments = async ({
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
@@ -291,6 +295,16 @@ export const findDocuments = async ({
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -429,6 +443,18 @@ export const findDocuments = async ({
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
|
||||
@@ -239,6 +239,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// CANCELLED: team-owned cancelled + team-email received cancelled docs
|
||||
const cancelledQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
|
||||
// Returns 0 if the team has no team email.
|
||||
const inboxQuery = teamEmail
|
||||
@@ -260,21 +274,23 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, inbox] = await Promise.all([
|
||||
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
cappedCount(cancelledQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
|
||||
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: draft,
|
||||
[ExtendedDocumentStatus.PENDING]: pending,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
};
|
||||
|
||||
@@ -34,8 +34,9 @@ type EnvelopeIdRef = Pick<Envelope, 'id'>;
|
||||
* Throws:
|
||||
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
|
||||
* the TSP lock — SES routes happily allow PENDING).
|
||||
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
|
||||
* stay consistent with the existing envelope-state error vocabulary.
|
||||
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` / `ENVELOPE_CANCELLED` for those
|
||||
* terminal states, to stay consistent with the existing envelope-state error
|
||||
* vocabulary.
|
||||
*/
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
|
||||
@@ -73,6 +74,7 @@ const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
|
||||
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
|
||||
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
|
||||
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
|
||||
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
|
||||
.otherwise(() => AppErrorCode.INVALID_REQUEST);
|
||||
|
||||
throw new AppError(errorCode, {
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
|
||||
'DOCUMENT_CREATED', // When the document is created.
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_CANCELLED', // When a privileged member cancels the document.
|
||||
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
@@ -296,6 +297,16 @@ export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document cancelled.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentCancelledSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED),
|
||||
data: z.object({
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field inserted.
|
||||
*/
|
||||
@@ -815,6 +826,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||
ZDocumentAuditLogEventDocumentCancelledSchema,
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
|
||||
|
||||
@@ -13,7 +13,27 @@ type File = {
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
export const putPdfFile = async (file: File) => {
|
||||
/**
|
||||
* Options for uploads that are not authorized by a logged-in session.
|
||||
*
|
||||
* Embedded authoring flows run cross-origin without a session cookie, so they
|
||||
* must authorize uploads with their embedding presign token instead.
|
||||
*/
|
||||
export type PutFileOptions = {
|
||||
presignToken?: string;
|
||||
};
|
||||
|
||||
const buildUploadAuthHeaders = (options?: PutFileOptions): Record<string, string> => {
|
||||
if (!options?.presignToken) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${options.presignToken}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Create a proper File object from the data
|
||||
@@ -25,6 +45,7 @@ export const putPdfFile = async (file: File) => {
|
||||
|
||||
const response = await fetch('/api/files/upload-pdf', {
|
||||
method: 'POST',
|
||||
headers: buildUploadAuthHeaders(options),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -41,12 +62,12 @@ export const putPdfFile = async (file: File) => {
|
||||
/**
|
||||
* Uploads a file to the appropriate storage location.
|
||||
*/
|
||||
export const putFile = async (file: File) => {
|
||||
export const putFile = async (file: File, options?: PutFileOptions) => {
|
||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||
|
||||
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
||||
.with('s3', async () => putFileInObjectStorage(file, {}))
|
||||
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
|
||||
.with('s3', async () => putFileInObjectStorage(file, {}, options))
|
||||
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
|
||||
.otherwise(async () => putFileInDatabase(file));
|
||||
};
|
||||
|
||||
@@ -63,11 +84,12 @@ const putFileInDatabase = async (file: File) => {
|
||||
};
|
||||
};
|
||||
|
||||
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
|
||||
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
|
||||
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildUploadAuthHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
|
||||
@@ -355,6 +355,14 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
you: msg`You deleted the document`,
|
||||
user: msg`${user} deleted the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document cancelled`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You cancelled the document`,
|
||||
user: msg`${user} cancelled the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `System auto inserted fields`,
|
||||
|
||||
@@ -12,7 +12,9 @@ import { mapRecipientToLegacyRecipient } from './recipients';
|
||||
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
|
||||
const status = typeof document === 'string' ? document : document.status;
|
||||
|
||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||
return (
|
||||
status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED || status === DocumentStatus.CANCELLED
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -242,12 +242,13 @@ export const getEnvelopeItemPermissions = (
|
||||
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
|
||||
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
|
||||
): EnvelopeItemPermissions => {
|
||||
// Always reject completed/rejected/deleted envelopes.
|
||||
// Always reject completed/rejected/cancelled/deleted envelopes.
|
||||
if (
|
||||
envelope.completedAt ||
|
||||
envelope.deletedAt ||
|
||||
envelope.status === DocumentStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.COMPLETED
|
||||
envelope.status === DocumentStatus.COMPLETED ||
|
||||
envelope.status === DocumentStatus.CANCELLED
|
||||
) {
|
||||
return {
|
||||
canTitleBeChanged: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
|
||||
import { type TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
|
||||
import type { DocumentVisibility, OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
@@ -235,3 +235,11 @@ export const extractDerivedTeamSettings = (
|
||||
|
||||
return derivedSettings;
|
||||
};
|
||||
|
||||
export const isMemberManagerOrAbove = (role: TeamMemberRole) => {
|
||||
return role === TeamMemberRole.ADMIN || role === TeamMemberRole.MANAGER;
|
||||
};
|
||||
|
||||
export const isMemberAdmin = (role: TeamMemberRole) => {
|
||||
return role === TeamMemberRole.ADMIN;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "DocumentStatus" ADD VALUE 'CANCELLED';
|
||||
@@ -382,6 +382,7 @@ enum DocumentStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum DocumentSource {
|
||||
|
||||
@@ -722,6 +722,91 @@ export const seedCompletedDocument = async (
|
||||
return document;
|
||||
};
|
||||
|
||||
export const seedCancelledDocument = async (
|
||||
sender: User,
|
||||
teamId: number,
|
||||
recipients: (User | string)[],
|
||||
options: CreateDocumentOptions = {},
|
||||
) => {
|
||||
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
|
||||
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: {},
|
||||
});
|
||||
|
||||
const documentId = await incrementDocumentId();
|
||||
|
||||
const document = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
teamId,
|
||||
title: `[TEST] Document ${key} - Cancelled`,
|
||||
status: DocumentStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
envelopeItems: {
|
||||
create: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: `[TEST] Document ${key} - Cancelled`,
|
||||
documentDataId: documentData.id,
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
userId: sender.id,
|
||||
...createDocumentOptions,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const email = typeof recipient === 'string' ? recipient : recipient.email;
|
||||
const name = typeof recipient === 'string' ? recipient : (recipient.name ?? '');
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
token: nanoid(),
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
envelopeId: document.id,
|
||||
fields: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: false,
|
||||
customText: name,
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
envelopeId: document.id,
|
||||
envelopeItemId: document.envelopeItems[0].id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create 5 team documents:
|
||||
* - Completed document with 2 recipients.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelop
|
||||
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { DocumentDataType, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentDataType, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -58,7 +58,12 @@ export const downloadDocumentBetaRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed, so its data is the unsigned original.
|
||||
// Treat it as not-completed here so a "signed" version is never served for it.
|
||||
// REJECTED and COMPLETED keep their prior behavior.
|
||||
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
|
||||
|
||||
if (version === 'signed' && !hasSignedArtifact) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not completed yet.',
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-e
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -60,7 +60,9 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed/completed, so a signing certificate
|
||||
// must not be generated for it. REJECTED and COMPLETED keep their prior behavior.
|
||||
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
|
||||
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
|
||||
[ExtendedDocumentStatus.PENDING]: z.number(),
|
||||
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
||||
[ExtendedDocumentStatus.REJECTED]: z.number(),
|
||||
[ExtendedDocumentStatus.CANCELLED]: z.number(),
|
||||
[ExtendedDocumentStatus.INBOX]: z.number(),
|
||||
[ExtendedDocumentStatus.ALL]: z.number(),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZBulkCancelEnvelopesRequestSchema, ZBulkCancelEnvelopesResponseSchema } from './bulk-cancel-envelopes.types';
|
||||
|
||||
export const bulkCancelEnvelopesRoute = authenticatedProcedure
|
||||
.input(ZBulkCancelEnvelopesRequestSchema)
|
||||
.output(ZBulkCancelEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds, reason } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelling only applies to documents. Templates are filtered out here so
|
||||
// selecting a template never counts towards a cancellation.
|
||||
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await pMap(
|
||||
envelopes,
|
||||
async (envelope) => {
|
||||
const { id: envelopeId } = envelope;
|
||||
|
||||
try {
|
||||
await cancelDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
reason,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
envelopeId,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{
|
||||
envelopeId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to cancel envelope during bulk cancel',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
envelopeId,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: 10,
|
||||
stopOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
const cancelledCount = results.filter((r) => r.success).length;
|
||||
const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId);
|
||||
|
||||
// Include envelope IDs that were not attempted (unauthorized/not found/not a document).
|
||||
const attemptedIds = new Set(envelopes.map((e) => e.id));
|
||||
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
|
||||
|
||||
return {
|
||||
cancelledCount,
|
||||
failedIds: [...failedIds, ...unattemptedIds],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Keeping this as a private API for a little while until we're sure it's stable
|
||||
// and the request/response schemas are finalized.
|
||||
|
||||
export const ZBulkCancelEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe('The IDs of the envelopes to cancel. The maximum number of envelopes you can cancel at once is 100.'),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZBulkCancelEnvelopesResponseSchema = z.object({
|
||||
cancelledCount: z.number(),
|
||||
failedIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TBulkCancelEnvelopesRequest = z.infer<typeof ZBulkCancelEnvelopesRequestSchema>;
|
||||
export type TBulkCancelEnvelopesResponse = z.infer<typeof ZBulkCancelEnvelopesResponseSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
cancelEnvelopeMeta,
|
||||
ZCancelEnvelopeRequestSchema,
|
||||
ZCancelEnvelopeResponseSchema,
|
||||
} from './cancel-envelope.types';
|
||||
|
||||
export const cancelEnvelopeRoute = authenticatedProcedure
|
||||
.meta(cancelEnvelopeMeta)
|
||||
.input(ZCancelEnvelopeRequestSchema)
|
||||
.output(ZCancelEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { envelopeId, reason } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
await cancelDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
reason,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const cancelEnvelopeMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/cancel',
|
||||
summary: 'Cancel envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCancelEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TCancelEnvelopeRequest = z.infer<typeof ZCancelEnvelopeRequestSchema>;
|
||||
export type TCancelEnvelopeResponse = z.infer<typeof ZCancelEnvelopeResponseSchema>;
|
||||
@@ -3,8 +3,10 @@ import { createAttachmentRoute } from './attachment/create-attachment';
|
||||
import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { bulkCancelEnvelopesRoute } from './bulk-cancel-envelopes';
|
||||
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
|
||||
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
|
||||
import { cancelEnvelopeRoute } from './cancel-envelope';
|
||||
import { createEnvelopeRoute } from './create-envelope';
|
||||
import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
@@ -85,6 +87,7 @@ export const envelopeRouter = router({
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
delete: bulkDeleteEnvelopesRoute,
|
||||
cancel: bulkCancelEnvelopesRoute,
|
||||
},
|
||||
editor: {
|
||||
get: getEditorEnvelopeRoute,
|
||||
@@ -95,6 +98,7 @@ export const envelopeRouter = router({
|
||||
use: useEnvelopeRoute,
|
||||
update: updateEnvelopeRoute,
|
||||
delete: deleteEnvelopeRoute,
|
||||
cancel: cancelEnvelopeRoute,
|
||||
duplicate: duplicateEnvelopeRoute,
|
||||
saveAsTemplate: saveAsTemplateRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
|
||||
@@ -102,12 +102,14 @@ export const AddSubjectFormPartial = ({
|
||||
: msg`Send`,
|
||||
[DocumentStatus.COMPLETED]: msg`Update`,
|
||||
[DocumentStatus.REJECTED]: msg`Update`,
|
||||
[DocumentStatus.CANCELLED]: msg`Update`,
|
||||
},
|
||||
[DocumentDistributionMethod.NONE]: {
|
||||
[DocumentStatus.DRAFT]: msg`Generate Links`,
|
||||
[DocumentStatus.PENDING]: msg`View Document`,
|
||||
[DocumentStatus.COMPLETED]: msg`View Document`,
|
||||
[DocumentStatus.REJECTED]: msg`View Document`,
|
||||
[DocumentStatus.CANCELLED]: msg`View Document`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user