From 810a4eea3aa6385af5d8c5d409ed502885cb0268 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 13 Mar 2025 15:02:46 +1100 Subject: [PATCH 1/2] feat: better document rejection --- .../dialogs/document-delete-dialog.tsx | 4 +- .../document-certificate-download-button.tsx | 5 +- .../document/document-page-view-button.tsx | 3 +- .../document/document-page-view-dropdown.tsx | 3 +- .../document-page-view-recipients.tsx | 3 +- .../general/document/document-status.tsx | 8 +- .../tables/documents-table-action-button.tsx | 3 +- .../documents-table-action-dropdown.tsx | 3 +- .../app/components/tables/documents-table.tsx | 4 +- .../_authenticated+/admin+/documents.$id.tsx | 4 +- .../_authenticated+/documents.$id._index.tsx | 3 + .../_authenticated+/documents.$id.edit.tsx | 5 +- .../_authenticated+/documents._index.tsx | 1 + .../_internal+/[__htmltopdf]+/certificate.tsx | 56 +++++++--- .../_recipient+/sign.$token+/_index.tsx | 2 +- .../_recipient+/sign.$token+/complete.tsx | 7 +- apps/remix/app/routes/embed+/sign.$url.tsx | 5 +- packages/api/v1/implementation.ts | 19 ++-- packages/api/v1/schema.ts | 2 +- .../template-document-cancel.tsx | 8 ++ packages/email/templates/document-cancel.tsx | 2 + packages/lib/constants/document.ts | 3 + packages/lib/jobs/client.ts | 2 + .../send-document-cancelled-emails.handler.ts | 105 ++++++++++++++++++ .../emails/send-document-cancelled-emails.ts | 33 ++++++ .../internal/seal-document.handler.ts | 57 +++++++--- .../server-only/admin/get-documents-stats.ts | 1 + .../server-only/document/delete-document.ts | 3 +- .../server-only/document/find-documents.ts | 51 +++++++++ .../get-document-certificate-audit-logs.ts | 4 + .../lib/server-only/document/get-stats.ts | 5 + .../document/reject-document-with-token.ts | 44 +++----- .../server-only/document/resend-document.tsx | 3 +- .../lib/server-only/document/seal-document.ts | 43 +++++-- .../server-only/document/send-document.tsx | 3 +- .../pdf/add-rejection-stamp-to-pdf.ts | 87 +++++++++++++++ packages/lib/server-only/user/delete-user.ts | 2 +- .../lib/universal/extract-request-metadata.ts | 8 +- packages/lib/utils/document.ts | 8 ++ .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/seed/documents.ts | 4 +- packages/trpc/server/admin-router/router.ts | 5 +- .../trpc/server/document-router/router.ts | 5 +- .../trpc/server/document-router/schema.ts | 1 + .../primitives/document-flow/add-subject.tsx | 2 + 46 files changed, 522 insertions(+), 110 deletions(-) create mode 100644 packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts create mode 100644 packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts create mode 100644 packages/lib/utils/document.ts create mode 100644 packages/prisma/migrations/20250311103458_add_rejected_document_status/migration.sql diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index a169686a66..c89e346a00 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; 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 { trpc as trpcReact } from '@documenso/trpc/react'; @@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({ )) - .with(DocumentStatus.COMPLETED, () => ( + .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (

By deleting this document, the following will occur: diff --git a/apps/remix/app/components/general/document/document-certificate-download-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx index bdf2d2ac98..7584fe81b6 100644 --- a/apps/remix/app/components/general/document/document-certificate-download-button.tsx +++ b/apps/remix/app/components/general/document/document-certificate-download-button.tsx @@ -1,9 +1,10 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { DocumentStatus } from '@prisma/client'; +import type { DocumentStatus } from '@prisma/client'; import { DownloadIcon } from 'lucide-react'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({ className={cn('w-full sm:w-auto', className)} loading={isPending} variant="outline" - disabled={documentStatus !== DocumentStatus.COMPLETED} + disabled={!isDocumentCompleted(documentStatus)} onClick={() => void onDownloadCertificatesClick()} > {!isPending && } diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index e5b76c5450..97e797976c 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -9,6 +9,7 @@ import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps const isRecipient = !!recipient; const isPending = document.status === DocumentStatus.PENDING; - const isComplete = document.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(document); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index 26118c9c88..21f4ce4818 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -20,6 +20,7 @@ import { useNavigate } from 'react-router'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; 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 isPending = document.status === DocumentStatus.PENDING; const isDeleted = document.deletedAt !== null; - const isComplete = document.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(document); const isCurrentTeamDocument = team && document.team?.url === team.url; const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); diff --git a/apps/remix/app/components/general/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx index 854b41e08d..2e413a8adb 100644 --- a/apps/remix/app/components/general/document/document-page-view-recipients.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -17,6 +17,7 @@ import { Link } from 'react-router'; import { match } from 'ts-pattern'; 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 { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { SignatureIcon } from '@documenso/ui/icons/signature'; @@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({ Recipients - {document.status !== DocumentStatus.COMPLETED && ( + {!isDocumentCompleted(document.status) && ( icon: File, 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: { label: msg`Inbox`, labelExtended: msg`Document inbox`, diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index c2fd7cfb45..97230c3595 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -9,6 +9,7 @@ import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(row.status); const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; const isCurrentTeamDocument = team && row.team?.url === team.url; diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index c4a6ccd3bf..ea0f11fc1e 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -22,6 +22,7 @@ import { Link } from 'react-router'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; @@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo // const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; + const isComplete = isDocumentCompleted(row.status); // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isCurrentTeamDocument = team && row.team?.url === team.url; const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index f4f5b3cb5a..4f6dc0050c 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -9,8 +9,8 @@ import { match } from 'ts-pattern'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; 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 { DataTableColumnDef } 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`), cell: ({ row }) => - (!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && ( + (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (

diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx index 4215573da2..db1b4d0e8e 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component variant="outline" loading={isResealDocumentLoading} disabled={document.recipients.some( - (recipient) => recipient.signingStatus !== SigningStatus.SIGNED, + (recipient) => + recipient.signingStatus !== SigningStatus.SIGNED && + recipient.signingStatus !== SigningStatus.REJECTED, )} onClick={() => resealDocument({ id: document.id })} > diff --git a/apps/remix/app/routes/_authenticated+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/documents.$id._index.tsx index fd3c8c241e..23d9b371fb 100644 --- a/apps/remix/app/routes/_authenticated+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/documents.$id._index.tsx @@ -220,6 +220,9 @@ export default function DocumentPage() { .with(DocumentStatus.COMPLETED, () => ( This document has been signed by all recipients )) + .with(DocumentStatus.REJECTED, () => ( + This document has been rejected by a recipient + )) .with(DocumentStatus.DRAFT, () => ( This document is currently a draft and has not been sent )) diff --git a/apps/remix/app/routes/_authenticated+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/documents.$id.edit.tsx index 2acfdc88df..ba91b24b30 100644 --- a/apps/remix/app/routes/_authenticated+/documents.$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/documents.$id.edit.tsx @@ -1,5 +1,5 @@ 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 { Link, redirect } from 'react-router'; 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 { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { DocumentEditForm } from '~/components/general/document/document-edit-form'; @@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw redirect(documentRootPath); } - if (document.status === InternalDocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { throw redirect(`${documentRootPath}/${documentId}`); } diff --git a/apps/remix/app/routes/_authenticated+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/documents._index.tsx index f730b8e89e..e8aa3b8dbd 100644 --- a/apps/remix/app/routes/_authenticated+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/documents._index.tsx @@ -50,6 +50,7 @@ export default function DocumentsPage() { [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.ALL]: 0, }); diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index bbfb8b9cdb..59dccf8944 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -1,6 +1,6 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { FieldType } from '@prisma/client'; +import { FieldType, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { redirect } from 'react-router'; 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.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,25 +289,42 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)

-

- {_(msg`Signed`)}:{' '} - - {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`)} - -

+ {logs.DOCUMENT_RECIPIENT_REJECTED[0] ? ( +

+ {_(msg`Rejected`)}:{' '} + + {logs.DOCUMENT_RECIPIENT_REJECTED[0] + ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt) + .setLocale(APP_I18N_OPTIONS.defaultLocale) + .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') + : _(msg`Unknown`)} + +

+ ) : ( +

+ {_(msg`Signed`)}:{' '} + + {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`)} + +

+ )}

{_(msg`Reason`)}:{' '} - {_( - isOwner(recipient.email) - ? FRIENDLY_SIGNING_REASONS['__OWNER__'] - : FRIENDLY_SIGNING_REASONS[recipient.role], - )} + {recipient.signingStatus === SigningStatus.REJECTED + ? recipient.rejectionReason + : _( + isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role], + )}

diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index af24f87553..98e0089a91 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -160,7 +160,7 @@ export default function SigningPage() { recipientWithFields, } = data; - if (document.deletedAt) { + if (document.deletedAt || document.status === DocumentStatus.REJECTED) { return (
- {document.status === DocumentStatus.COMPLETED ? ( + {isDocumentCompleted(document.status) ? ( ) : ( { - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return; } diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx index bb874fc50b..7418e64b50 100644 --- a/apps/remix/app/routes/embed+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/sign.$url.tsx @@ -1,4 +1,4 @@ -import { DocumentStatus, RecipientRole } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; import { data } from 'react-router'; 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 { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page'; @@ -168,7 +169,7 @@ export default function EmbedSignDocumentPage() { recipient={recipient} fields={fields} metadata={document.documentMeta} - isCompleted={document.status === DocumentStatus.COMPLETED} + isCompleted={isDocumentCompleted(document.status)} hidePoweredBy={ isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy } diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index d2c900d37f..793d563863 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,5 +1,5 @@ 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 { match } from 'ts-pattern'; @@ -50,6 +50,7 @@ import { getPresignGetUrl, getPresignPostUrl, } from '@documenso/lib/universal/upload/server-actions'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; 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 { status: 400, body: { @@ -669,7 +670,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, body: { @@ -772,7 +773,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, body: { @@ -863,7 +864,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, body: { @@ -922,7 +923,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, body: { @@ -987,7 +988,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, 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 { status: 400, body: { @@ -1237,7 +1238,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { }; } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return { status: 400, body: { diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 40f79174ac..5f71ddd1ef 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -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.', }), }) - .or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); + .or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; diff --git a/packages/email/template-components/template-document-cancel.tsx b/packages/email/template-components/template-document-cancel.tsx index 14a72624d9..38c3db1087 100644 --- a/packages/email/template-components/template-document-cancel.tsx +++ b/packages/email/template-components/template-document-cancel.tsx @@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps { inviterEmail: string; documentName: string; assetBaseUrl: string; + cancellationReason?: string; } export const TemplateDocumentCancel = ({ inviterName, documentName, assetBaseUrl, + cancellationReason, }: TemplateDocumentCancelProps) => { return ( <> @@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({ You don't need to sign it anymore. + + {cancellationReason && ( + + Reason for cancellation: {cancellationReason} + + )} ); diff --git a/packages/email/templates/document-cancel.tsx b/packages/email/templates/document-cancel.tsx index 7b49f32a48..4ecea75b7d 100644 --- a/packages/email/templates/document-cancel.tsx +++ b/packages/email/templates/document-cancel.tsx @@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({ inviterEmail = 'lucas@documenso.com', documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', + cancellationReason, }: DocumentCancelEmailTemplateProps) => { const { _ } = useLingui(); const branding = useBranding(); @@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({ inviterEmail={inviterEmail} documentName={documentName} assetBaseUrl={assetBaseUrl} + cancellationReason={cancellationReason} /> diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index e0c78bf1aa..faec13cde2 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -8,6 +8,9 @@ export const DOCUMENT_STATUS: { [DocumentStatus.COMPLETED]: { description: msg`Completed`, }, + [DocumentStatus.REJECTED]: { + description: msg`Rejected`, + }, [DocumentStatus.DRAFT]: { description: msg`Draft`, }, diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 36944f9a31..713d928d8c 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,5 +1,6 @@ import { JobClient } from './client/client'; 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_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'; @@ -24,6 +25,7 @@ export const jobsClient = new JobClient([ SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, + SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION, BULK_SEND_TEMPLATE_JOB_DEFINITION, ] as const); diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts new file mode 100644 index 0000000000..93194a2e13 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -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, + }); + }), + ); + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts new file mode 100644 index 0000000000..ac21d806ed --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index dfcf6dacc6..b2b8cdee4e 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -6,9 +6,11 @@ import { PDFDocument } from 'pdf-lib'; import { prisma } from '@documenso/prisma'; import { signPdf } from '@documenso/signing'; +import { AppError, AppErrorCode } from '../../../errors/app-error'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client'; 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 { flattenForm } from '../../../server-only/pdf/flatten-form'; import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf'; @@ -22,6 +24,7 @@ import { import { getFileServerSide } from '../../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'; import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers'; +import { isDocumentCompleted } from '../../../utils/document'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; import type { JobRunIO } from '../../client/_internal/job'; import type { TSealDocumentJobDefinition } from './seal-document'; @@ -38,11 +41,6 @@ export const run = async ({ const document = await prisma.document.findFirstOrThrow({ where: { id: documentId, - recipients: { - every: { - signingStatus: SigningStatus.SIGNED, - }, - }, }, include: { 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 // after it has already run through the update task further below. // eslint-disable-next-line @typescript-eslint/require-await @@ -91,9 +99,15 @@ export const run = async ({ }, }); - if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) { - throw new Error(`Document ${document.id} has unsigned recipients`); - } + // 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 ?? ''; const fields = await prisma.field.findMany({ 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`); } @@ -132,6 +147,11 @@ export const run = async ({ flattenForm(pdfDoc); flattenAnnotations(pdfDoc); + // Add rejection stamp if the document is rejected + if (isRejected && rejectionReason) { + await addRejectionStampToPdf(pdfDoc, rejectionReason); + } + if (certificateData) { const certificateDoc = await PDFDocument.load(certificateData); @@ -160,8 +180,11 @@ export const run = async ({ const { name } = path.parse(document.title); + // Add suffix based on document status + const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf'; + const documentData = await putPdfFileServerSide({ - name: `${name}_signed.pdf`, + name: `${name}${suffix}`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdfBuffer), }); @@ -177,6 +200,7 @@ export const run = async ({ event: 'App: Document Sealed', properties: { documentId: document.id, + isRejected, }, }); } @@ -189,12 +213,14 @@ export const run = async ({ }, }); + console.log('Updating document to have status:', isRejected ? 'REJECTED' : 'COMPLETED'); + await tx.document.update({ where: { id: document.id, }, data: { - status: DocumentStatus.COMPLETED, + status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED, completedAt: new Date(), }, }); @@ -216,6 +242,7 @@ export const run = async ({ user: null, data: { transactionId: nanoid(), + ...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}), }, }), }); @@ -223,9 +250,9 @@ export const run = 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; } @@ -246,7 +273,9 @@ export const run = async ({ }); await triggerWebhook({ - event: WebhookTriggerEvents.DOCUMENT_COMPLETED, + event: isRejected + ? WebhookTriggerEvents.DOCUMENT_REJECTED + : WebhookTriggerEvents.DOCUMENT_COMPLETED, data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), userId: updatedDocument.userId, teamId: updatedDocument.teamId ?? undefined, diff --git a/packages/lib/server-only/admin/get-documents-stats.ts b/packages/lib/server-only/admin/get-documents-stats.ts index e0d53373fa..e91344f341 100644 --- a/packages/lib/server-only/admin/get-documents-stats.ts +++ b/packages/lib/server-only/admin/get-documents-stats.ts @@ -13,6 +13,7 @@ export const getDocumentStats = async () => { [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.ALL]: 0, }; diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8865882143..36d8076bbc 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -26,6 +26,7 @@ import { mapDocumentToWebhookDocumentPayload, } from '../../types/webhook-payload'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; +import { isDocumentCompleted } from '../../utils/document'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; @@ -161,7 +162,7 @@ const handleDocumentOwnerDelete = async ({ } // Soft delete completed documents. - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { return await prisma.$transaction(async (tx) => { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index f3e7d772a3..a7a689ee0d 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -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(); }; @@ -548,5 +566,38 @@ const findTeamDocumentsFilter = ( 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(); }; diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts index e517a46082..676118506c 100644 --- a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts +++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts @@ -16,6 +16,7 @@ export const getDocumentCertificateAuditLogs = async ({ type: { in: [ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, ], @@ -29,6 +30,9 @@ export const getDocumentCertificateAuditLogs = async ({ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter( (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( (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, ), diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 448eb9c0d7..7b12f72c5d 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -44,6 +44,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.REJECTED]: 0, [ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.ALL]: 0, }; @@ -64,6 +65,10 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta if (stat.status === ExtendedDocumentStatus.PENDING) { stats[ExtendedDocumentStatus.PENDING] += stat._count._all; } + + if (stat.status === ExtendedDocumentStatus.REJECTED) { + stats[ExtendedDocumentStatus.REJECTED] += stat._count._all; + } }); Object.keys(stats).forEach((key) => { diff --git a/packages/lib/server-only/document/reject-document-with-token.ts b/packages/lib/server-only/document/reject-document-with-token.ts index 68a1d9ba4c..f0c5764ef6 100644 --- a/packages/lib/server-only/document/reject-document-with-token.ts +++ b/packages/lib/server-only/document/reject-document-with-token.ts @@ -1,18 +1,12 @@ import { SigningStatus } from '@prisma/client'; -import { WebhookTriggerEvents } from '@prisma/client'; import { jobs } from '@documenso/lib/jobs/client'; import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; 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 { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type RejectDocumentWithTokenOptions = { token: string; @@ -84,36 +78,32 @@ export async function rejectDocumentWithToken({ }), ]); - // Send email notifications + // Trigger the seal document job to process the document asynchronously await jobs.triggerJob({ - name: 'send.signing.rejected.emails', + name: 'internal.seal-document', payload: { - recipientId: recipient.id, documentId, + requestMetadata, }, }); - // Get the updated document with all recipients - const updatedDocument = await prisma.document.findFirst({ - where: { - id: document.id, - }, - include: { - recipients: true, - documentMeta: true, + // Send email notifications to the rejecting recipient + await jobs.triggerJob({ + name: 'send.signing.rejected.emails', + payload: { + recipientId: recipient.id, + documentId, }, }); - 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, + // Send cancellation emails to other recipients + await jobs.triggerJob({ + name: 'send.document.cancelled.emails', + payload: { + documentId, + cancellationReason: reason, + requestMetadata, + }, }); return updatedRecipient; diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 60ff594fba..94c056e45b 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -20,6 +20,7 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; +import { isDocumentCompleted } from '../../utils/document'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; import { getDocumentWhereInput } from './get-document-by-id'; @@ -88,7 +89,7 @@ export const resendDocument = async ({ 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'); } diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 4d4d2dea1e..2f328a40f1 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -18,6 +18,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; +import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenForm } from '../pdf/flatten-form'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; @@ -41,11 +42,6 @@ export const sealDocument = async ({ const document = await prisma.document.findFirstOrThrow({ where: { id: documentId, - recipients: { - every: { - signingStatus: SigningStatus.SIGNED, - }, - }, }, include: { 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`); } @@ -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`); } @@ -119,6 +130,11 @@ export const sealDocument = async ({ flattenForm(doc); flattenAnnotations(doc); + // Add rejection stamp if the document is rejected + if (isRejected && rejectionReason) { + await addRejectionStampToPdf(doc, rejectionReason); + } + if (certificateData) { const certificate = await PDFDocument.load(certificateData); @@ -142,8 +158,11 @@ export const sealDocument = async ({ const { name } = path.parse(document.title); + // Add suffix based on document status + const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf'; + const { data: newData } = await putPdfFileServerSide({ - name: `${name}_signed.pdf`, + name: `${name}${suffix}`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdfBuffer), }); @@ -156,6 +175,7 @@ export const sealDocument = async ({ event: 'App: Document Sealed', properties: { documentId: document.id, + isRejected, }, }); } @@ -166,7 +186,7 @@ export const sealDocument = async ({ id: document.id, }, data: { - status: DocumentStatus.COMPLETED, + status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED, completedAt: new Date(), }, }); @@ -188,6 +208,7 @@ export const sealDocument = async ({ user: null, data: { transactionId: nanoid(), + ...(isRejected ? { isRejected: true, rejectionReason } : {}), }, }), }); @@ -209,7 +230,9 @@ export const sealDocument = async ({ }); await triggerWebhook({ - event: WebhookTriggerEvents.DOCUMENT_COMPLETED, + event: isRejected + ? WebhookTriggerEvents.DOCUMENT_REJECTED + : WebhookTriggerEvents.DOCUMENT_COMPLETED, data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), userId: document.userId, teamId: document.teamId ?? undefined, diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 0832d5fa76..cf9f53bfad 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -20,6 +20,7 @@ import { } from '../../types/webhook-payload'; import { getFileServerSide } from '../../universal/upload/get-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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -74,7 +75,7 @@ export const sendDocument = async ({ throw new Error('Document has no recipients'); } - if (document.status === DocumentStatus.COMPLETED) { + if (isDocumentCompleted(document.status)) { throw new Error('Can not send completed document'); } diff --git a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts new file mode 100644 index 0000000000..fe4853a5b0 --- /dev/null +++ b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts @@ -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 { + 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; +} diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 99562f8002..414e13a462 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -29,7 +29,7 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { where: { userId: user.id, status: { - in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED], + in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED], }, }, data: { diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts index a91f75537a..6e24dc74a0 100644 --- a/packages/lib/universal/extract-request-metadata.ts +++ b/packages/lib/universal/extract-request-metadata.ts @@ -40,7 +40,13 @@ export type ApiRequestMetadata = { }; 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 userAgent = req.headers.get('user-agent'); diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts new file mode 100644 index 0000000000..52ceca6279 --- /dev/null +++ b/packages/lib/utils/document.ts @@ -0,0 +1,8 @@ +import type { Document } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; + +export const isDocumentCompleted = (document: Pick | DocumentStatus) => { + const status = typeof document === 'string' ? document : document.status; + + return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED; +}; diff --git a/packages/prisma/migrations/20250311103458_add_rejected_document_status/migration.sql b/packages/prisma/migrations/20250311103458_add_rejected_document_status/migration.sql new file mode 100644 index 0000000000..05e9e42430 --- /dev/null +++ b/packages/prisma/migrations/20250311103458_add_rejected_document_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DocumentStatus" ADD VALUE 'REJECTED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2b12388918..f8eb478974 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -297,6 +297,7 @@ enum DocumentStatus { DRAFT PENDING COMPLETED + REJECTED } enum DocumentSource { diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 93ca14a53e..f7b4042117 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -31,6 +31,7 @@ type DocumentToSeed = { export const seedDocuments = async (documents: DocumentToSeed[]) => { await Promise.all( + // eslint-disable-next-line @typescript-eslint/require-await documents.map(async (document, i) => match(document.type) .with(DocumentStatus.DRAFT, async () => @@ -50,8 +51,7 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { key: i, createDocumentOptions: document.documentOptions, }), - ) - .exhaustive(), + ), ), ); }; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index d2a93e3fb9..d4a0c712d4 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,5 +1,3 @@ -import { DocumentStatus } from '@prisma/client'; - import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; 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 { enableUser } from '@documenso/lib/server-only/user/enable-user'; 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 { @@ -70,7 +69,7 @@ export const adminRouter = router({ const document = await getEntireDocument({ id }); - const isResealing = document.status === DocumentStatus.COMPLETED; + const isResealing = isDocumentCompleted(document.status); return await sealDocument({ documentId: id, isResealing }); }), diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 1b11fe3b19..8ce1b7eb8b 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,4 +1,4 @@ -import { DocumentDataType, DocumentStatus } from '@prisma/client'; +import { DocumentDataType } from '@prisma/client'; import { TRPCError } from '@trpc/server'; 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 { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -659,7 +660,7 @@ export const documentRouter = router({ teamId, }); - if (document.status !== DocumentStatus.COMPLETED) { + if (!isDocumentCompleted(document.status)) { throw new AppError('DOCUMENT_NOT_COMPLETE'); } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index b8d5256de6..ea4d36c570 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -144,6 +144,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ [ExtendedDocumentStatus.DRAFT]: z.number(), [ExtendedDocumentStatus.PENDING]: z.number(), [ExtendedDocumentStatus.COMPLETED]: z.number(), + [ExtendedDocumentStatus.REJECTED]: z.number(), [ExtendedDocumentStatus.INBOX]: z.number(), [ExtendedDocumentStatus.ALL]: z.number(), }), diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index aec6decee6..93ff374dee 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -79,11 +79,13 @@ export const AddSubjectFormPartial = ({ ? msg`Resend` : msg`Send`, [DocumentStatus.COMPLETED]: msg`Update`, + [DocumentStatus.REJECTED]: msg`Update`, }, [DocumentDistributionMethod.NONE]: { [DocumentStatus.DRAFT]: msg`Generate Links`, [DocumentStatus.PENDING]: msg`View Document`, [DocumentStatus.COMPLETED]: msg`View Document`, + [DocumentStatus.REJECTED]: msg`View Document`, }, }; From 7c659af12d1772009897d99b3206b6e2e09be881 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 13 Mar 2025 15:07:05 +1100 Subject: [PATCH 2/2] chore: remove console.log --- packages/lib/jobs/definitions/internal/seal-document.handler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index b2b8cdee4e..a6e7e692dc 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -213,8 +213,6 @@ export const run = async ({ }, }); - console.log('Updating document to have status:', isRejected ? 'REJECTED' : 'COMPLETED'); - await tx.document.update({ where: { id: document.id,