feat: better document rejection (#1702)
Improves the existing document rejection process by actually marking a document as completed cancelling further actions. ## Related Issue N/A ## Changes Made - Added a new rejection status for documents - Updated a million areas that check for document completion - Updated email sending, so rejection is confirmed for the rejecting recipient while other recipients are notified that the document is now cancelled. ## Testing Performed - Ran the testing suite to ensure there are no regressions. - Performed manual testing of current core flows.
This commit is contained in:
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import type { DocumentStatus } from '@prisma/client';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({
|
|||||||
className={cn('w-full sm:w-auto', className)}
|
className={cn('w-full sm:w-auto', className)}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
disabled={!isDocumentCompleted(documentStatus)}
|
||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -63,7 +64,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
@@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{!isDocumentCompleted(document.status) && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
@@ -36,6 +36,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
|||||||
icon: File,
|
icon: File,
|
||||||
color: 'text-yellow-500 dark:text-yellow-200',
|
color: 'text-yellow-500 dark:text-yellow-200',
|
||||||
},
|
},
|
||||||
|
REJECTED: {
|
||||||
|
label: msg`Rejected`,
|
||||||
|
labelExtended: msg`Document rejected`,
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-red-500 dark:text-red-300',
|
||||||
|
},
|
||||||
INBOX: {
|
INBOX: {
|
||||||
label: msg`Inbox`,
|
label: msg`Inbox`,
|
||||||
labelExtended: msg`Document inbox`,
|
labelExtended: msg`Document inbox`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
// const isRecipient = !!recipient;
|
// const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
@@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
|||||||
{
|
{
|
||||||
header: _(msg`Actions`),
|
header: _(msg`Actions`),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DocumentsTableActionButton row={row.original} />
|
<DocumentsTableActionButton row={row.original} />
|
||||||
<DocumentsTableActionDropdown row={row.original} />
|
<DocumentsTableActionDropdown row={row.original} />
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.recipients.some(
|
disabled={document.recipients.some(
|
||||||
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
(recipient) =>
|
||||||
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export default function DocumentPage() {
|
|||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<Trans>This document has been signed by all recipients</Trans>
|
<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.DRAFT, () => (
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -9,6 +9,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
|
|||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
@@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function DocumentsPage() {
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType, SigningStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -159,6 +159,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||||
log.data.recipientId === recipientId,
|
log.data.recipientId === recipientId,
|
||||||
),
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
|
||||||
|
].filter(
|
||||||
|
(log) =>
|
||||||
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
|
||||||
|
log.data.recipientId === recipientId,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,21 +289,38 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
|
||||||
<span className="inline-block">
|
<span className="inline-block">
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
|
||||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
: _(msg`Unknown`)}
|
: _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
|
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||||
|
? DateTime.fromJSDate(
|
||||||
|
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
|
||||||
|
)
|
||||||
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
|
: _(msg`Unknown`)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||||
<span className="inline-block">
|
<span className="inline-block">
|
||||||
{_(
|
{recipient.signingStatus === SigningStatus.REJECTED
|
||||||
|
? recipient.rejectionReason
|
||||||
|
: _(
|
||||||
isOwner(recipient.email)
|
isOwner(recipient.email)
|
||||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||||
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function SigningPage() {
|
|||||||
recipientWithFields,
|
recipientWithFields,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (document.deletedAt) {
|
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||||
<SigningCard3D
|
<SigningCard3D
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
@@ -205,12 +206,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
{document.status === DocumentStatus.COMPLETED ? (
|
{isDocumentCompleted(document.status) ? (
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={!isDocumentCompleted(document.status)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DocumentDialog
|
<DocumentDialog
|
||||||
@@ -268,7 +269,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
|
|||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
|||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
|
||||||
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||||
@@ -168,7 +169,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
hidePoweredBy={
|
hidePoweredBy={
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { DocumentDataType, DocumentStatus, SigningStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { tsr } from '@ts-rest/serverless/fetch';
|
import { tsr } from '@ts-rest/serverless/fetch';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
getPresignGetUrl,
|
getPresignGetUrl,
|
||||||
getPresignPostUrl,
|
getPresignPostUrl,
|
||||||
} from '@documenso/lib/universal/upload/server-actions';
|
} from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status !== DocumentStatus.COMPLETED) {
|
if (!isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -669,7 +670,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -772,7 +773,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -863,7 +864,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -922,7 +923,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -987,7 +988,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: { message: 'Document is already completed' },
|
body: { message: 'Document is already completed' },
|
||||||
@@ -1149,7 +1150,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -1237,7 +1238,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
|
|||||||
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
|
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
||||||
|
|
||||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps {
|
|||||||
inviterEmail: string;
|
inviterEmail: string;
|
||||||
documentName: string;
|
documentName: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
|
cancellationReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentCancel = ({
|
export const TemplateDocumentCancel = ({
|
||||||
inviterName,
|
inviterName,
|
||||||
documentName,
|
documentName,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
|
cancellationReason,
|
||||||
}: TemplateDocumentCancelProps) => {
|
}: TemplateDocumentCancelProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({
|
|||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
<Trans>You don't need to sign it anymore.</Trans>
|
<Trans>You don't need to sign it anymore.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{cancellationReason && (
|
||||||
|
<Text className="mt-4 text-center text-base">
|
||||||
|
<Trans>Reason for cancellation: {cancellationReason}</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail = 'lucas@documenso.com',
|
inviterEmail = 'lucas@documenso.com',
|
||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
cancellationReason,
|
||||||
}: DocumentCancelEmailTemplateProps) => {
|
}: DocumentCancelEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail={inviterEmail}
|
inviterEmail={inviterEmail}
|
||||||
documentName={documentName}
|
documentName={documentName}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
cancellationReason={cancellationReason}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const DOCUMENT_STATUS: {
|
|||||||
[DocumentStatus.COMPLETED]: {
|
[DocumentStatus.COMPLETED]: {
|
||||||
description: msg`Completed`,
|
description: msg`Completed`,
|
||||||
},
|
},
|
||||||
|
[DocumentStatus.REJECTED]: {
|
||||||
|
description: msg`Rejected`,
|
||||||
|
},
|
||||||
[DocumentStatus.DRAFT]: {
|
[DocumentStatus.DRAFT]: {
|
||||||
description: msg`Draft`,
|
description: msg`Draft`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { JobClient } from './client/client';
|
import { JobClient } from './client/client';
|
||||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||||
|
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||||
@@ -24,6 +25,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||||
|
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
|
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||||
|
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||||
|
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||||
|
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
|
||||||
|
|
||||||
|
export const run = async ({
|
||||||
|
payload,
|
||||||
|
io,
|
||||||
|
}: {
|
||||||
|
payload: TSendDocumentCancelledEmailsJobDefinition;
|
||||||
|
io: JobRunIO;
|
||||||
|
}) => {
|
||||||
|
const { documentId, cancellationReason } = payload;
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
teamEmail: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
teamGlobalSettings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentMeta, user: documentOwner } = document;
|
||||||
|
|
||||||
|
// Check if document cancellation emails are enabled
|
||||||
|
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
|
||||||
|
|
||||||
|
if (!isEmailEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = await getI18nInstance(documentMeta?.language);
|
||||||
|
|
||||||
|
// Send cancellation emails to all recipients who have been sent the document or viewed it
|
||||||
|
const recipientsToNotify = document.recipients.filter(
|
||||||
|
(recipient) =>
|
||||||
|
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||||
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
|
|
||||||
|
await io.runTask('send-cancellation-emails', async () => {
|
||||||
|
await Promise.all(
|
||||||
|
recipientsToNotify.map(async (recipient) => {
|
||||||
|
const template = createElement(DocumentCancelTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
inviterName: documentOwner.name || undefined,
|
||||||
|
inviterEmail: documentOwner.email,
|
||||||
|
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||||
|
cancellationReason: cancellationReason || 'The document has been cancelled.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const branding = document.team?.teamGlobalSettings
|
||||||
|
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
|
||||||
|
renderEmailWithI18N(template, {
|
||||||
|
lang: documentMeta?.language,
|
||||||
|
branding,
|
||||||
|
plainText: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
name: recipient.name,
|
||||||
|
address: recipient.email,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: i18n._(msg`Document "${document.title}" Cancelled`),
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID = 'send.document.cancelled.emails';
|
||||||
|
|
||||||
|
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
cancellationReason: z.string().optional(),
|
||||||
|
requestMetadata: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSendDocumentCancelledEmailsJobDefinition = z.infer<
|
||||||
|
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION = {
|
||||||
|
id: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
||||||
|
name: 'Send Document Cancelled Emails',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
||||||
|
schema: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./send-document-cancelled-emails.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
||||||
|
TSendDocumentCancelledEmailsJobDefinition
|
||||||
|
>;
|
||||||
@@ -6,9 +6,11 @@ import { PDFDocument } from 'pdf-lib';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||||
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
||||||
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
||||||
|
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
||||||
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||||
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
||||||
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
||||||
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
||||||
|
import { isDocumentCompleted } from '../../../utils/document';
|
||||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||||
import type { JobRunIO } from '../../client/_internal/job';
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
import type { TSealDocumentJobDefinition } from './seal-document';
|
import type { TSealDocumentJobDefinition } from './seal-document';
|
||||||
@@ -38,11 +41,6 @@ export const run = async ({
|
|||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
recipients: {
|
|
||||||
every: {
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
@@ -59,6 +57,16 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isComplete =
|
||||||
|
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||||
|
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Document is not complete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Seems silly but we need to do this in case the job is re-ran
|
// Seems silly but we need to do this in case the job is re-ran
|
||||||
// after it has already run through the update task further below.
|
// after it has already run through the update task further below.
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
@@ -91,9 +99,15 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||||
throw new Error(`Document ${document.id} has unsigned recipients`);
|
const rejectedRecipient = recipients.find(
|
||||||
}
|
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRejected = Boolean(rejectedRecipient);
|
||||||
|
|
||||||
|
// Get the rejection reason from the rejected recipient
|
||||||
|
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||||
|
|
||||||
const fields = await prisma.field.findMany({
|
const fields = await prisma.field.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -104,7 +118,8 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
// Skip the field check if the document is rejected
|
||||||
|
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +147,11 @@ export const run = async ({
|
|||||||
flattenForm(pdfDoc);
|
flattenForm(pdfDoc);
|
||||||
flattenAnnotations(pdfDoc);
|
flattenAnnotations(pdfDoc);
|
||||||
|
|
||||||
|
// Add rejection stamp if the document is rejected
|
||||||
|
if (isRejected && rejectionReason) {
|
||||||
|
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
||||||
|
}
|
||||||
|
|
||||||
if (certificateData) {
|
if (certificateData) {
|
||||||
const certificateDoc = await PDFDocument.load(certificateData);
|
const certificateDoc = await PDFDocument.load(certificateData);
|
||||||
|
|
||||||
@@ -160,8 +180,11 @@ export const run = async ({
|
|||||||
|
|
||||||
const { name } = path.parse(document.title);
|
const { name } = path.parse(document.title);
|
||||||
|
|
||||||
|
// Add suffix based on document status
|
||||||
|
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||||
|
|
||||||
const documentData = await putPdfFileServerSide({
|
const documentData = await putPdfFileServerSide({
|
||||||
name: `${name}_signed.pdf`,
|
name: `${name}${suffix}`,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||||
});
|
});
|
||||||
@@ -177,6 +200,7 @@ export const run = async ({
|
|||||||
event: 'App: Document Sealed',
|
event: 'App: Document Sealed',
|
||||||
properties: {
|
properties: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
isRejected,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,7 +218,7 @@ export const run = async ({
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: DocumentStatus.COMPLETED,
|
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -216,6 +240,7 @@ export const run = async ({
|
|||||||
user: null,
|
user: null,
|
||||||
data: {
|
data: {
|
||||||
transactionId: nanoid(),
|
transactionId: nanoid(),
|
||||||
|
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -223,9 +248,9 @@ export const run = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await io.runTask('send-completed-email', async () => {
|
await io.runTask('send-completed-email', async () => {
|
||||||
let shouldSendCompletedEmail = sendEmail && !isResealing;
|
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
||||||
|
|
||||||
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
|
if (isResealing && !isDocumentCompleted(document.status)) {
|
||||||
shouldSendCompletedEmail = sendEmail;
|
shouldSendCompletedEmail = sendEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +271,9 @@ export const run = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
event: isRejected
|
||||||
|
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
||||||
|
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||||
userId: updatedDocument.userId,
|
userId: updatedDocument.userId,
|
||||||
teamId: updatedDocument.teamId ?? undefined,
|
teamId: updatedDocument.teamId ?? undefined,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const getDocumentStats = async () => {
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
} from '../../types/webhook-payload';
|
} from '../../types/webhook-payload';
|
||||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||||
@@ -161,7 +162,7 @@ const handleDocumentOwnerDelete = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete completed documents.
|
// Soft delete completed documents.
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
|
|||||||
@@ -356,6 +356,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.REJECTED, () => ({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
teamId: null,
|
||||||
|
status: ExtendedDocumentStatus.REJECTED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: ExtendedDocumentStatus.REJECTED,
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
signingStatus: SigningStatus.REJECTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -548,5 +566,38 @@ const findTeamDocumentsFilter = (
|
|||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
})
|
})
|
||||||
|
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||||
|
const filter: Prisma.DocumentWhereInput = {
|
||||||
|
status: ExtendedDocumentStatus.REJECTED,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
teamId: team.id,
|
||||||
|
OR: visibilityFilters,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (teamEmail && filter.OR) {
|
||||||
|
filter.OR.push(
|
||||||
|
{
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
email: teamEmail,
|
||||||
|
signingStatus: SigningStatus.REJECTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OR: visibilityFilters,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
email: teamEmail,
|
||||||
|
},
|
||||||
|
OR: visibilityFilters,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
})
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const getDocumentCertificateAuditLogs = async ({
|
|||||||
type: {
|
type: {
|
||||||
in: [
|
in: [
|
||||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
],
|
],
|
||||||
@@ -29,6 +30,9 @@ export const getDocumentCertificateAuditLogs = async ({
|
|||||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
),
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs.filter(
|
||||||
|
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||||
|
),
|
||||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,10 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
|||||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stat.status === ExtendedDocumentStatus.REJECTED) {
|
||||||
|
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(stats).forEach((key) => {
|
Object.keys(stats).forEach((key) => {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { SigningStatus } from '@prisma/client';
|
import { SigningStatus } from '@prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@prisma/client';
|
|
||||||
|
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import {
|
|
||||||
ZWebhookDocumentSchema,
|
|
||||||
mapDocumentToWebhookDocumentPayload,
|
|
||||||
} from '../../types/webhook-payload';
|
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|
||||||
|
|
||||||
export type RejectDocumentWithTokenOptions = {
|
export type RejectDocumentWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -84,7 +78,16 @@ export async function rejectDocumentWithToken({
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Send email notifications
|
// Trigger the seal document job to process the document asynchronously
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.seal-document',
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
requestMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notifications to the rejecting recipient
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.signing.rejected.emails',
|
name: 'send.signing.rejected.emails',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -93,27 +96,14 @@ export async function rejectDocumentWithToken({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the updated document with all recipients
|
// Send cancellation emails to other recipients
|
||||||
const updatedDocument = await prisma.document.findFirst({
|
await jobs.triggerJob({
|
||||||
where: {
|
name: 'send.document.cancelled.emails',
|
||||||
id: document.id,
|
payload: {
|
||||||
|
documentId,
|
||||||
|
cancellationReason: reason,
|
||||||
|
requestMetadata,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
recipients: true,
|
|
||||||
documentMeta: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updatedDocument) {
|
|
||||||
throw new Error('Document not found after update');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger webhook for document rejection
|
|
||||||
await triggerWebhook({
|
|
||||||
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
|
|
||||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
|
||||||
userId: document.userId,
|
|
||||||
teamId: document.teamId ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedRecipient;
|
return updatedRecipient;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
@@ -88,7 +89,7 @@ export const resendDocument = async ({
|
|||||||
throw new Error('Can not send draft document');
|
throw new Error('Can not send draft document');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
||||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||||
|
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||||
import { flattenForm } from '../pdf/flatten-form';
|
import { flattenForm } from '../pdf/flatten-form';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
@@ -41,11 +42,6 @@ export const sealDocument = async ({
|
|||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
recipients: {
|
|
||||||
every: {
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentData: true,
|
documentData: true,
|
||||||
@@ -78,7 +74,21 @@ export const sealDocument = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||||
|
const rejectedRecipient = recipients.find(
|
||||||
|
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRejected = Boolean(rejectedRecipient);
|
||||||
|
|
||||||
|
// Get the rejection reason from the rejected recipient
|
||||||
|
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||||
|
|
||||||
|
// If the document is not rejected, ensure all recipients have signed
|
||||||
|
if (
|
||||||
|
!isRejected &&
|
||||||
|
recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)
|
||||||
|
) {
|
||||||
throw new Error(`Document ${document.id} has unsigned recipients`);
|
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +101,8 @@ export const sealDocument = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
// Skip the field check if the document is rejected
|
||||||
|
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +130,11 @@ export const sealDocument = async ({
|
|||||||
flattenForm(doc);
|
flattenForm(doc);
|
||||||
flattenAnnotations(doc);
|
flattenAnnotations(doc);
|
||||||
|
|
||||||
|
// Add rejection stamp if the document is rejected
|
||||||
|
if (isRejected && rejectionReason) {
|
||||||
|
await addRejectionStampToPdf(doc, rejectionReason);
|
||||||
|
}
|
||||||
|
|
||||||
if (certificateData) {
|
if (certificateData) {
|
||||||
const certificate = await PDFDocument.load(certificateData);
|
const certificate = await PDFDocument.load(certificateData);
|
||||||
|
|
||||||
@@ -142,8 +158,11 @@ export const sealDocument = async ({
|
|||||||
|
|
||||||
const { name } = path.parse(document.title);
|
const { name } = path.parse(document.title);
|
||||||
|
|
||||||
|
// Add suffix based on document status
|
||||||
|
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||||
|
|
||||||
const { data: newData } = await putPdfFileServerSide({
|
const { data: newData } = await putPdfFileServerSide({
|
||||||
name: `${name}_signed.pdf`,
|
name: `${name}${suffix}`,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||||
});
|
});
|
||||||
@@ -156,6 +175,7 @@ export const sealDocument = async ({
|
|||||||
event: 'App: Document Sealed',
|
event: 'App: Document Sealed',
|
||||||
properties: {
|
properties: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
isRejected,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -166,7 +186,7 @@ export const sealDocument = async ({
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: DocumentStatus.COMPLETED,
|
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -188,6 +208,7 @@ export const sealDocument = async ({
|
|||||||
user: null,
|
user: null,
|
||||||
data: {
|
data: {
|
||||||
transactionId: nanoid(),
|
transactionId: nanoid(),
|
||||||
|
...(isRejected ? { isRejected: true, rejectionReason } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -209,7 +230,9 @@ export const sealDocument = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
event: isRejected
|
||||||
|
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
||||||
|
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||||
userId: document.userId,
|
userId: document.userId,
|
||||||
teamId: document.teamId ?? undefined,
|
teamId: document.teamId ?? undefined,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../../types/webhook-payload';
|
} from '../../types/webhook-payload';
|
||||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const sendDocument = async ({
|
|||||||
throw new Error('Document has no recipients');
|
throw new Error('Document has no recipients');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
Normal file
87
packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
|
import type { PDFDocument } from 'pdf-lib';
|
||||||
|
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rejection stamp to each page of a PDF document.
|
||||||
|
* The stamp is placed in the center of the page.
|
||||||
|
*/
|
||||||
|
export async function addRejectionStampToPdf(
|
||||||
|
pdfDoc: PDFDocument,
|
||||||
|
reason: string,
|
||||||
|
): Promise<PDFDocument> {
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
pdfDoc.registerFontkit(fontkit);
|
||||||
|
|
||||||
|
const fontBytes = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||||
|
async (res) => res.arrayBuffer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const font = await pdfDoc.embedFont(fontBytes, {
|
||||||
|
customName: 'Noto',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const page = pages[i];
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
|
// Draw the "REJECTED" text
|
||||||
|
const rejectedTitleText = 'DOCUMENT REJECTED';
|
||||||
|
const rejectedTitleFontSize = 36;
|
||||||
|
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
|
||||||
|
|
||||||
|
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
|
||||||
|
rejectedTitleTextField.acroField.setDefaultAppearance(
|
||||||
|
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedTitleTextField.updateAppearances(font);
|
||||||
|
|
||||||
|
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
|
||||||
|
rejectedTitleTextField.setText(rejectedTitleText);
|
||||||
|
rejectedTitleTextField.setAlignment(TextAlignment.Center);
|
||||||
|
|
||||||
|
const rejectedTitleTextWidth =
|
||||||
|
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
|
||||||
|
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
|
||||||
|
|
||||||
|
// Calculate the center position of the page
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
|
||||||
|
// Position the title text at the center of the page
|
||||||
|
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
|
||||||
|
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
|
||||||
|
|
||||||
|
// Add padding for the rectangle
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
// Draw the stamp background
|
||||||
|
page.drawRectangle({
|
||||||
|
x: rejectedTitleTextX - padding / 2,
|
||||||
|
y: rejectedTitleTextY - padding / 2,
|
||||||
|
width: rejectedTitleTextWidth + padding,
|
||||||
|
height: rejectedTitleTextHeight + padding,
|
||||||
|
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||||
|
borderWidth: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
rejectedTitleTextField.addToPage(page, {
|
||||||
|
x: rejectedTitleTextX,
|
||||||
|
y: rejectedTitleTextY,
|
||||||
|
width: rejectedTitleTextWidth,
|
||||||
|
height: rejectedTitleTextHeight,
|
||||||
|
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||||
|
backgroundColor: undefined,
|
||||||
|
borderWidth: 0,
|
||||||
|
borderColor: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfDoc;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
status: {
|
status: {
|
||||||
in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED],
|
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ export type ApiRequestMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const extractRequestMetadata = (req: Request): RequestMetadata => {
|
export const extractRequestMetadata = (req: Request): RequestMetadata => {
|
||||||
const parsedIp = ZIpSchema.safeParse(req.headers.get('x-forwarded-for'));
|
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||||
|
const ip = forwardedFor
|
||||||
|
?.split(',')
|
||||||
|
.map((ip) => ip.trim())
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
const parsedIp = ZIpSchema.safeParse(ip);
|
||||||
|
|
||||||
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
const userAgent = req.headers.get('user-agent');
|
const userAgent = req.headers.get('user-agent');
|
||||||
|
|||||||
8
packages/lib/utils/document.ts
Normal file
8
packages/lib/utils/document.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Document } from '@prisma/client';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
|
||||||
|
const status = typeof document === 'string' ? document : document.status;
|
||||||
|
|
||||||
|
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "DocumentStatus" ADD VALUE 'REJECTED';
|
||||||
@@ -297,6 +297,7 @@ enum DocumentStatus {
|
|||||||
DRAFT
|
DRAFT
|
||||||
PENDING
|
PENDING
|
||||||
COMPLETED
|
COMPLETED
|
||||||
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DocumentSource {
|
enum DocumentSource {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type DocumentToSeed = {
|
|||||||
|
|
||||||
export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
documents.map(async (document, i) =>
|
documents.map(async (document, i) =>
|
||||||
match(document.type)
|
match(document.type)
|
||||||
.with(DocumentStatus.DRAFT, async () =>
|
.with(DocumentStatus.DRAFT, async () =>
|
||||||
@@ -50,8 +51,7 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
|||||||
key: i,
|
key: i,
|
||||||
createDocumentOptions: document.documentOptions,
|
createDocumentOptions: document.documentOptions,
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.exhaustive(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
||||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
@@ -13,6 +11,7 @@ import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
|||||||
import { disableUser } from '@documenso/lib/server-only/user/disable-user';
|
import { disableUser } from '@documenso/lib/server-only/user/disable-user';
|
||||||
import { enableUser } from '@documenso/lib/server-only/user/enable-user';
|
import { enableUser } from '@documenso/lib/server-only/user/enable-user';
|
||||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
|
||||||
import { adminProcedure, router } from '../trpc';
|
import { adminProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -70,7 +69,7 @@ export const adminRouter = router({
|
|||||||
|
|
||||||
const document = await getEntireDocument({ id });
|
const document = await getEntireDocument({ id });
|
||||||
|
|
||||||
const isResealing = document.status === DocumentStatus.COMPLETED;
|
const isResealing = isDocumentCompleted(document.status);
|
||||||
|
|
||||||
return await sealDocument({ documentId: id, isResealing });
|
return await sealDocument({ documentId: id, isResealing });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentDataType, DocumentStatus } from '@prisma/client';
|
import { DocumentDataType } from '@prisma/client';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document'
|
|||||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -659,7 +660,7 @@ export const documentRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (document.status !== DocumentStatus.COMPLETED) {
|
if (!isDocumentCompleted(document.status)) {
|
||||||
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
|
|||||||
[ExtendedDocumentStatus.DRAFT]: z.number(),
|
[ExtendedDocumentStatus.DRAFT]: z.number(),
|
||||||
[ExtendedDocumentStatus.PENDING]: z.number(),
|
[ExtendedDocumentStatus.PENDING]: z.number(),
|
||||||
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: z.number(),
|
||||||
[ExtendedDocumentStatus.INBOX]: z.number(),
|
[ExtendedDocumentStatus.INBOX]: z.number(),
|
||||||
[ExtendedDocumentStatus.ALL]: z.number(),
|
[ExtendedDocumentStatus.ALL]: z.number(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -79,11 +79,13 @@ export const AddSubjectFormPartial = ({
|
|||||||
? msg`Resend`
|
? msg`Resend`
|
||||||
: msg`Send`,
|
: msg`Send`,
|
||||||
[DocumentStatus.COMPLETED]: msg`Update`,
|
[DocumentStatus.COMPLETED]: msg`Update`,
|
||||||
|
[DocumentStatus.REJECTED]: msg`Update`,
|
||||||
},
|
},
|
||||||
[DocumentDistributionMethod.NONE]: {
|
[DocumentDistributionMethod.NONE]: {
|
||||||
[DocumentStatus.DRAFT]: msg`Generate Links`,
|
[DocumentStatus.DRAFT]: msg`Generate Links`,
|
||||||
[DocumentStatus.PENDING]: msg`View Document`,
|
[DocumentStatus.PENDING]: msg`View Document`,
|
||||||
[DocumentStatus.COMPLETED]: msg`View Document`,
|
[DocumentStatus.COMPLETED]: msg`View Document`,
|
||||||
|
[DocumentStatus.REJECTED]: msg`View Document`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user