diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 27b1ae208..1267931d6 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog. While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). -Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) +Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) Best from Hamburg\ Timur diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 389528bf8..a1b56257a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { readStatus: 'OPENED', signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', + role: 'SIGNER', }; const onFileDrop = async (file: File) => { diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 9910ef111..ecddf1190 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -2,13 +2,13 @@ import Link from 'next/link'; -import { Download, Edit, Pencil } from 'lucide-react'; +import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; const onDownloadClick = async () => { try { @@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { } }; + // TODO: Consider if want to keep this logic for hiding viewing for CC'ers + if (recipient?.role === RecipientRole.CC && isComplete === false) { + return null; + } + return match({ isOwner, isRecipient, @@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( )) .with({ isComplete: true }, () => ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f14321b35..e1d9b64bb 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -5,9 +5,11 @@ import { useState } from 'react'; import Link from 'next/link'; import { + CheckCircle, Copy, Download, Edit, + EyeIcon, Loader, MoreHorizontal, Pencil, @@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; @@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Action - - - - Sign - - + {recipient?.role !== RecipientRole.CC && ( + + + {recipient?.role === RecipientRole.VIEWER && ( + <> + + View + + )} + + {recipient?.role === RecipientRole.SIGNER && ( + <> + + Sign + + )} + + {recipient?.role === RecipientRole.APPROVER && ( + <> + + Approve + + )} + + + )} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 3d5814113..a64831804 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-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 { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -94,7 +94,10 @@ export default async function CompletedSigningPage({ ))}

- You have signed + You have + {recipient.role === RecipientRole.SIGNER && ' signed '} + {recipient.role === RecipientRole.VIEWER && ' viewed '} + {recipient.role === RecipientRole.APPROVER && ' approved '} "{truncatedTitle}"

diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 65dab5e61..7105baafd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; -import type { Document, Field, Recipient } from '@documenso/prisma/client'; +import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; @@ -96,74 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
-
-

Sign Document

+
+

+ {recipient.role === RecipientRole.VIEWER && 'View Document'} + {recipient.role === RecipientRole.SIGNER && 'Sign Document'} + {recipient.role === RecipientRole.APPROVER && 'Approve Document'} +

-

- Please review the document before signing. -

+ {recipient.role === RecipientRole.VIEWER ? ( + <> +

+ Please mark as viewed to complete +

-
+
-
-
-
- +
+
+
+ - setFullName(e.target.value.trimStart())} - /> + +
+ + ) : ( + <> +

+ Please review the document before signing. +

-
- +
- - - { - setSignature(value); - }} +
+
+
+ + + setFullName(e.target.value.trimStart())} /> - - +
+ +
+ + + + + { + setSignature(value); + }} + /> + + +
+
+ +
+ + + +
-
- -
- - - -
-
+ + )}
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 004c59329..7e025593c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -14,7 +14,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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; -import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp

- {document.User.name} ({document.User.email}) has invited you to sign this document. + {document.User.name} ({document.User.email}) has invited you to{' '} + {recipient.role === RecipientRole.VIEWER && 'view'} + {recipient.role === RecipientRole.SIGNER && 'sign'} + {recipient.role === RecipientRole.APPROVER && 'approve'} this document.

diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 1e86e99bc..a9aedbc3d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { Document, Field } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -17,6 +18,7 @@ export type SignDialogProps = { fields: Field[]; fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; + role: RecipientRole; }; export const SignDialog = ({ @@ -25,6 +27,7 @@ export const SignDialog = ({ fields, fieldsValidated, onSignatureComplete, + role, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); const truncatedTitle = truncateTitle(document.title); @@ -45,9 +48,18 @@ export const SignDialog = ({
-
Sign Document
+
+ {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} + {role === RecipientRole.SIGNER && 'Sign Document'} + {role === RecipientRole.APPROVER && 'Approve Document'} +
- You are about to finish signing "{truncatedTitle}". Are you sure? + {role === RecipientRole.VIEWER && + `You are about to finish viewing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.SIGNER && + `You are about to finish signing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.APPROVER && + `You are about to finish approving "${truncatedTitle}". Are you sure?`}
@@ -71,7 +83,9 @@ export const SignDialog = ({ loading={isSubmitting} onClick={onSignatureComplete} > - Sign + {role === RecipientRole.VIEWER && 'Mark as Viewed'} + {role === RecipientRole.SIGNER && 'Sign'} + {role === RecipientRole.APPROVER && 'Approve'} diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index d04b3a998..46182c36e 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - - {recipient.email} +
+
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
+
); } diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7429d8ee5..bd7bea2b0 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,4 +1,5 @@ import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { @@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({ type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - {recipient.email} +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
))} diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index 216a3183d..b958e9029 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -1,3 +1,6 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { RecipientRole } from '@documenso/prisma/client'; + import { Button, Section, Text } from '../components'; import { TemplateDocumentImage } from './template-document-image'; @@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps { documentName: string; signDocumentLink: string; assetBaseUrl: string; + role: RecipientRole; } export const TemplateDocumentInvite = ({ @@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({ documentName, signDocumentLink, assetBaseUrl, + role, }: TemplateDocumentInviteProps) => { + const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; + return ( <>
- {inviterName} has invited you to sign + {inviterName} has invited you to {actionVerb.toLowerCase()}
"{documentName}"
- Continue by signing the document. + Continue by {progressiveVerb.toLowerCase()} the document.
@@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({ className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline" href={signDocumentLink} > - Sign Document + {actionVerb} Document
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d6a45d5fc..d3bceb872 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -1,3 +1,5 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { RecipientRole } from '@documenso/prisma/client'; import config from '@documenso/tailwind-config'; import { @@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentInviteEmailTemplateProps = Partial & { customBody?: string; + role: RecipientRole; }; export const DocumentInviteEmailTemplate = ({ @@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({ signDocumentLink = 'https://documenso.com', assetBaseUrl = 'http://localhost:3002', customBody, + role, }: DocumentInviteEmailTemplateProps) => { - const previewText = `${inviterName} has invited you to sign ${documentName}`; + const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); + + const previewText = `${inviterName} has invited you to ${action} ${documentName}`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({ documentName={documentName} signDocumentLink={signDocumentLink} assetBaseUrl={assetBaseUrl} + role={role} /> @@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({ {customBody ? (
{customBody}
) : ( - `${inviterName} has invited you to sign the document "${documentName}".` + `${inviterName} has invited you to ${action} the document "${documentName}".` )} diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index 8b5a8a528..44993796a 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -1,10 +1,10 @@ import type { Recipient } from '@documenso/prisma/client'; -import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client'; export const getRecipientType = (recipient: Recipient) => { if ( - recipient.sendStatus === SendStatus.SENT && - recipient.signingStatus === SigningStatus.SIGNED + recipient.role === RecipientRole.CC || + (recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED) ) { return 'completed'; } diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts new file mode 100644 index 000000000..920cf1f32 --- /dev/null +++ b/packages/lib/constants/recipient-roles.ts @@ -0,0 +1,26 @@ +import { RecipientRole } from '@documenso/prisma/client'; + +export const RECIPIENT_ROLES_DESCRIPTION: { + [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string }; +} = { + [RecipientRole.APPROVER]: { + actionVerb: 'Approve', + progressiveVerb: 'Approving', + roleName: 'Approver', + }, + [RecipientRole.CC]: { + actionVerb: 'CC', + progressiveVerb: 'CC', + roleName: 'CC', + }, + [RecipientRole.SIGNER]: { + actionVerb: 'Sign', + progressiveVerb: 'Signing', + roleName: 'Signer', + }, + [RecipientRole.VIEWER]: { + actionVerb: 'View', + progressiveVerb: 'Viewing', + roleName: 'Viewer', + }, +}; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 2929c515b..8d367dbe4 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import type { Document, Prisma } from '@documenso/prisma/client'; -import { SigningStatus } from '@documenso/prisma/client'; +import { RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { FindResultSet } from '../../types/find-result-set'; @@ -87,6 +87,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, @@ -109,6 +112,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index da4ffcb58..4c7b66be8 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; export type ResendDocumentOptions = { documentId: number; @@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD await Promise.all( document.Recipient.map(async (recipient) => { + if (recipient.role === RecipientRole.CC) { + return; + } + const { email, name } = recipient; const customEmailTemplate = { @@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD assetBaseUrl, signDocumentLink, customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + role: recipient.role, }); + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + await mailer.sendMail({ to: { address: email, @@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : 'Please sign this document', + : `Please ${actionVerb.toLowerCase()} this document`, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 5fa4b1a00..b24288c3e 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { signPdf } from '@documenso/signing'; import { getFile } from '../../universal/upload/get-file'; @@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen const recipients = await prisma.recipient.findMany({ where: { documentId: document.id, + role: { + not: RecipientRole.CC, + }, }, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 25dc132ba..82b37852b 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; export type SendDocumentOptions = { documentId: number; @@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) await Promise.all( document.Recipient.map(async (recipient) => { + if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { + return; + } + const { email, name } = recipient; const customEmailTemplate = { @@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) 'document.name': document.title, }; - if (recipient.sendStatus === SendStatus.SENT) { - return; - } - const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`; @@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) assetBaseUrl, signDocumentLink, customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + role: recipient.role, }); + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + await mailer.sendMail({ to: { address: email, @@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : 'Please sign this document', + : `Please ${actionVerb.toLowerCase()} this document`, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 198f79be1..4917b213d 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { nanoid } from '../../universal/id'; @@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions { id?: number | null; email: string; name: string; + role: RecipientRole; }[]; } @@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({ update: { name: recipient.name, email: recipient.email, + role: recipient.role, documentId, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, create: { name: recipient.name, email: recipient.email, + role: recipient.role, token: nanoid(), documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, }), ), diff --git a/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql new file mode 100644 index 000000000..441132300 --- /dev/null +++ b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER'); + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 353a855ae..87d29d6b2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -209,6 +209,13 @@ enum SigningStatus { SIGNED } +enum RecipientRole { + CC + SIGNER + VIEWER + APPROVER +} + model Recipient { id Int @id @default(autoincrement()) documentId Int? @@ -218,6 +225,7 @@ model Recipient { token String expired DateTime? signedAt DateTime? + role RecipientRole @default(SIGNER) readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index c4389bdfb..5d8c23c27 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), @@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({ id: z.number().nullish(), email: z.string().min(1).email(), name: z.string(), + role: z.nativeEnum(RecipientRole), }), ), }); diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 09097895c..1ada3d0d3 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -25,6 +25,7 @@ export const recipientRouter = router({ id: signer.nativeId, email: signer.email, name: signer.name, + role: signer.role, })), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 8920e7672..a6b4e0d11 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { RecipientRole } from '@documenso/prisma/client'; + export const ZAddSignersMutationSchema = z .object({ documentId: z.number(), @@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z nativeId: z.number().optional(), email: z.string().email().min(1), name: z.string(), + role: z.nativeEnum(RecipientRole), }), ), }) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index afd09809d..74764df80 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; @@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; import { FieldType, SendStatus } from '@documenso/prisma/client'; import { cn } from '../../lib/utils'; @@ -102,6 +104,12 @@ export const AddFieldsFormPartial = ({ const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; + const isFieldsDisabled = + !selectedSigner || + hasSelectedSignerBeenSent || + selectedSigner?.role === RecipientRole.VIEWER || + selectedSigner?.role === RecipientRole.CC; + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [coords, setCoords] = useState({ x: 0, @@ -281,12 +289,28 @@ export const AddFieldsFormPartial = ({ setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); }, [recipients]); + const recipientsByRole = useMemo(() => { + const recipientsByRole: Record = { + CC: [], + VIEWER: [], + SIGNER: [], + APPROVER: [], + }; + + recipients.forEach((recipient) => { + recipientsByRole[recipient.role].push(recipient); + }); + + return recipientsByRole; + }, [recipients]); + return ( <> +
{selectedField && ( @@ -351,72 +375,94 @@ export const AddFieldsFormPartial = ({ + No recipient matching this description was found. - - {recipients.map((recipient, index) => ( - { - setSelectedSigner(recipient); - setShowRecipientsSelector(false); - }} - > - {recipient.sendStatus !== SendStatus.SENT ? ( - - ) : ( - - - - - - This document has already been sent to this recipient. You can no - longer edit this recipient. - - - )} + {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( + +
+ { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName + } +
- {recipient.name && ( + {recipients.length === 0 && ( +
+ No recipients with this role +
+ )} + + {recipients.map((recipient) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > - {recipient.name} ({recipient.email}) - - )} + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} - {!recipient.name && ( - - {recipient.email} + {!recipient.name && ( + {recipient.email} + )} - )} - - ))} -
+ +
+ {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} +
+
+ ))} +
+ ))}
)}
-
+
-
+
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index bd25cb87d..26aedcae7 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -4,19 +4,20 @@ import React, { useId } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; +import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { Button } from '../button'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; import { useToast } from '../use-toast'; import type { TAddSignersFormSchema } from './add-signers.types'; @@ -31,6 +32,13 @@ import { import { ShowFieldItem } from './show-field-item'; import type { DocumentFlowStep } from './types'; +const ROLE_ICONS: Record = { + SIGNER: , + APPROVER: , + CC: , + VIEWER: , +}; + export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; @@ -67,12 +75,14 @@ export const AddSignersFormPartial = ({ formId: String(recipient.id), name: recipient.name, email: recipient.email, + role: recipient.role, })) : [ { formId: initialId, name: '', email: '', + role: RecipientRole.SIGNER, }, ], }, @@ -104,6 +114,7 @@ export const AddSignersFormPartial = ({ formId: nanoid(12), name: '', email: '', + role: RecipientRole.SIGNER, }); }; @@ -189,6 +200,48 @@ export const AddSignersFormPartial = ({ /> +
+ ( + + )} + /> +
+