diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 5c659ad46..3030794ba 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -12,6 +12,7 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; +import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -177,8 +178,8 @@ export const EditDocumentForm = ({ stepIndex: 3, }, subject: { - title: msg`Add Subject`, - description: msg`Add the subject and message you wish to send to signers.`, + title: msg`Distribute Document`, + description: msg`Choose how the document will reach recipients`, stepIndex: 4, }, }; @@ -307,7 +308,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message } = data.meta; + const { subject, message, distributionMethod, emailSettings } = data.meta; try { await sendDocument({ @@ -316,16 +317,31 @@ export const EditDocumentForm = ({ meta: { subject, message, + distributionMethod, + emailSettings, }, }); - toast({ - title: _(msg`Document sent`), - description: _(msg`Your document has been sent successfully.`), - duration: 5000, - }); + if (distributionMethod === DocumentDistributionMethod.EMAIL) { + toast({ + title: _(msg`Document sent`), + description: _(msg`Your document has been sent successfully.`), + duration: 5000, + }); - router.push(documentRootPath); + router.push(documentRootPath); + return; + } + + if (document.status === DocumentStatus.DRAFT) { + toast({ + title: _(msg`Links Generated`), + description: _(msg`Signing links have been generated for this document.`), + duration: 5000, + }); + } else { + router.push(`${documentRootPath}/${document.id}`); + } } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 277f9d36f..65213c777 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -145,6 +145,7 @@ export const TemplatesDataTable = ({ diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index 8ff5eea07..ee07d8b29 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -17,7 +17,7 @@ import { } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import type { Recipient } from '@documenso/prisma/client'; -import { DocumentSigningOrder } from '@documenso/prisma/client'; +import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -49,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; const ZAddRecipientsForNewDocumentSchema = z .object({ - sendDocument: z.boolean(), + distributeDocument: z.boolean(), recipients: z.array( z.object({ id: z.number(), @@ -93,12 +93,14 @@ export type UseTemplateDialogProps = { templateId: number; templateSigningOrder?: DocumentSigningOrder | null; recipients: Recipient[]; + documentDistributionMethod?: DocumentDistributionMethod; documentRootPath: string; trigger?: React.ReactNode; }; export function UseTemplateDialog({ recipients, + documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentRootPath, templateId, templateSigningOrder, @@ -116,7 +118,7 @@ export function UseTemplateDialog({ const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: { - sendDocument: false, + distributeDocument: false, recipients: recipients .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .map((recipient) => { @@ -147,7 +149,7 @@ export function UseTemplateDialog({ templateId, teamId: team?.id, recipients: data.recipients, - sendDocument: data.sendDocument, + distributeDocument: data.distributeDocument, }); toast({ @@ -156,7 +158,16 @@ export function UseTemplateDialog({ duration: 5000, }); - router.push(`${documentRootPath}/${id}`); + let documentPath = `${documentRootPath}/${id}`; + + if ( + data.distributeDocument && + documentDistributionMethod === DocumentDistributionMethod.NONE + ) { + documentPath += '?action=view-signing-links'; + } + + router.push(documentPath); } catch (err) { const error = AppError.parseError(err); @@ -295,43 +306,76 @@ export function UseTemplateDialog({
(
- + )} + + {documentDistributionMethod === DocumentDistributionMethod.NONE && ( + + )}
)} @@ -347,10 +391,12 @@ export function UseTemplateDialog({ diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index d276ee36b..686346fba 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -106,7 +106,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => await page.getByRole('button', { name: 'Continue' }).click(); // Add subject and send - await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); await page.getByRole('button', { name: 'Send' }).click(); await page.waitForURL('/documents'); @@ -190,7 +190,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByRole('button', { name: 'Continue' }).click(); // Add subject and send - await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); await page.getByRole('button', { name: 'Send' }).click(); await page.waitForURL('/documents'); @@ -287,7 +287,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByRole('button', { name: 'Continue' }).click(); // Add subject and send - await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); await page.getByRole('button', { name: 'Send' }).click(); await page.waitForURL('/documents'); @@ -566,7 +566,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); await page.getByRole('button', { name: 'Send' }).click(); await page.waitForURL('/documents'); diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index 69bd62093..5b5ab2c5f 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -1,7 +1,7 @@ import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/macro'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; export const DOCUMENT_STATUS: { [status in DocumentStatus]: { description: MessageDescriptor }; @@ -16,3 +16,19 @@ export const DOCUMENT_STATUS: { description: msg`Pending`, }, }; + +type DocumentDistributionMethodTypeData = { + value: DocumentDistributionMethod; + description: MessageDescriptor; +}; + +export const DOCUMENT_DISTRIBUTION_METHODS: Record = { + [DocumentDistributionMethod.EMAIL]: { + value: DocumentDistributionMethod.EMAIL, + description: msg`Email`, + }, + [DocumentDistributionMethod.NONE]: { + value: DocumentDistributionMethod.NONE, + description: msg`None`, + }, +} satisfies Record; diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.ts b/packages/lib/jobs/definitions/emails/send-signing-email.ts index f272eba16..f069cdec2 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.ts @@ -21,6 +21,7 @@ import { RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../../constants/recipient-roles'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template'; @@ -81,6 +82,14 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = { return; } + const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientSigningRequest; + + if (!isRecipientSigningRequestEmailEnabled) { + return; + } + const customEmail = document?.documentMeta; const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK; const isTeamDocument = document.teamId !== null; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index daf8ec32c..c6f4fd7a3 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -7,9 +7,10 @@ import { diffDocumentMetaChanges, } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import type { DocumentSigningOrder } from '@documenso/prisma/client'; +import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; import type { SupportedLanguageCodes } from '../../constants/i18n'; +import type { TDocumentEmailSettings } from '../../types/document-email'; export type CreateDocumentMetaOptions = { documentId: number; @@ -19,7 +20,9 @@ export type CreateDocumentMetaOptions = { password?: string; dateFormat?: string; redirectUrl?: string; + emailSettings?: TDocumentEmailSettings; signingOrder?: DocumentSigningOrder; + distributionMethod?: DocumentDistributionMethod; typedSignatureEnabled?: boolean; language?: SupportedLanguageCodes; userId: number; @@ -36,6 +39,8 @@ export const upsertDocumentMeta = async ({ userId, redirectUrl, signingOrder, + emailSettings, + distributionMethod, typedSignatureEnabled, language, requestMetadata, @@ -88,6 +93,8 @@ export const upsertDocumentMeta = async ({ documentId, redirectUrl, signingOrder, + emailSettings, + distributionMethod, typedSignatureEnabled, language, }, @@ -99,6 +106,8 @@ export const upsertDocumentMeta = async ({ timezone, redirectUrl, signingOrder, + emailSettings, + distributionMethod, typedSignatureEnabled, language, }, diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 301d37bd2..f3f8146af 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -14,6 +14,7 @@ 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; @@ -178,6 +179,14 @@ const handleDocumentOwnerDelete = async ({ }); }); + const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).documentDeleted; + + if (!isDocumentDeleteEmailEnabled) { + return deletedDocument; + } + // Send cancellation emails to recipients. await Promise.all( document.Recipient.map(async (recipient) => { diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index ffe202d8e..dba415158 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -19,6 +19,7 @@ import type { Prisma } from '@documenso/prisma/client'; import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getDocumentWhereInput } from './get-document-by-id'; @@ -89,6 +90,14 @@ export const resendDocument = async ({ throw new Error('Can not send completed document'); } + const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientSigningRequest; + + if (!isRecipientSigningRequestEmailEnabled) { + return; + } + await Promise.all( document.Recipient.map(async (recipient) => { if (recipient.role === RecipientRole.CC) { diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 29413a3dd..b3254770e 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -10,6 +10,7 @@ import { DocumentSource } from '@documenso/prisma/client'; import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; @@ -66,8 +67,15 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo const i18n = await getI18nInstance(document.documentMeta?.language); - // If the document owner is not a recipient then send the email to them separately - if (!document.Recipient.find((recipient) => recipient.email === owner.email)) { + const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).documentCompleted; + + // If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately. + if ( + !document.Recipient.find((recipient) => recipient.email === owner.email) || + !isDocumentCompletedEmailEnabled + ) { const template = createElement(DocumentCompletedEmailTemplate, { documentName: document.title, assetBaseUrl, @@ -119,6 +127,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo }); } + if (!isDocumentCompletedEmailEnabled) { + return; + } + await Promise.all( document.Recipient.map(async (recipient) => { const customEmailTemplate = { diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index fa648de2b..96acfff4e 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -8,6 +8,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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; export interface SendDeleteEmailOptions { @@ -22,6 +23,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt }, include: { User: true, + documentMeta: true, }, }); @@ -29,6 +31,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt throw new Error('Document not found'); } + const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).documentDeleted; + + if (!isDocumentDeletedEmailEnabled) { + return; + } + const { email, name } = document.User; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 93ec12108..7266963b7 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -13,6 +13,7 @@ import { import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { jobs } from '../../jobs/client'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { getFile } from '../../universal/upload/get-file'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -29,7 +30,7 @@ export const sendDocument = async ({ documentId, userId, teamId, - sendEmail = true, + sendEmail, requestMetadata, }: SendDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -156,7 +157,14 @@ export const sendDocument = async ({ // throw new Error('Some signers have not been assigned a signature field.'); // } - if (sendEmail) { + const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientSigningRequest; + + // Only send email if one of the following is true: + // - It is explicitly set + // - The email is enabled for signing requests AND sendEmail is undefined + if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) { await Promise.all( recipientsToNotify.map(async (recipient) => { if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/server-only/document/send-pending-email.ts index 997d8cdbd..40d75a252 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/server-only/document/send-pending-email.ts @@ -8,6 +8,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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; export interface SendPendingEmailOptions { @@ -43,6 +44,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE throw new Error('Document has no recipients'); } + const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).documentPending; + + if (!isDocumentPendingEmailEnabled) { + return; + } + const [recipient] = document.Recipient; const { email, name } = recipient; diff --git a/packages/lib/server-only/document/super-delete-document.ts b/packages/lib/server-only/document/super-delete-document.ts index db72bb1fc..02c14d70a 100644 --- a/packages/lib/server-only/document/super-delete-document.ts +++ b/packages/lib/server-only/document/super-delete-document.ts @@ -13,6 +13,7 @@ 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; @@ -40,8 +41,16 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo const { status, User: user } = document; + const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).documentDeleted; + // if the document is pending, send cancellation emails to all recipients - if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { + if ( + status === DocumentStatus.PENDING && + document.Recipient.length > 0 && + isDocumentDeletedEmailEnabled + ) { await Promise.all( document.Recipient.map(async (recipient) => { if (recipient.sendStatus !== SendStatus.SENT) { 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 b9fc0e6af..dd76c0835 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -26,6 +26,7 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { canRecipientBeModified } from '../../utils/recipients'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; @@ -280,10 +281,14 @@ export const setRecipientsForDocument = async ({ }); }); + const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientRemoved; + // Send emails to deleted recipients. await Promise.all( removedRecipients.map(async (recipient) => { - if (recipient.sendStatus !== SendStatus.SENT) { + if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) { return; } diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 733ca9c86..bd27a44b7 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -275,6 +275,7 @@ export const createDocumentFromDirectTemplate = async ({ subject: metaEmailSubject, language: metaLanguage, signingOrder: metaSigningOrder, + distributionMethod: template.templateMeta?.distributionMethod, }, }, }, diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 04bf46dde..d503a5040 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,5 +1,6 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; +import type { DocumentDistributionMethod } from '@documenso/prisma/client'; import { DocumentSigningOrder, DocumentSource, @@ -62,6 +63,7 @@ export type CreateDocumentFromTemplateOptions = { redirectUrl?: string; signingOrder?: DocumentSigningOrder; language?: SupportedLanguageCodes; + distributionMethod?: DocumentDistributionMethod; }; requestMetadata?: RequestMetadata; }; @@ -177,6 +179,9 @@ export const createDocumentFromTemplate = async ({ password: override?.password || template.templateMeta?.password, dateFormat: override?.dateFormat || template.templateMeta?.dateFormat, redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl, + distributionMethod: + override?.distributionMethod || template.templateMeta?.distributionMethod, + emailSettings: template.templateMeta?.emailSettings || undefined, signingOrder: override?.signingOrder || template.templateMeta?.signingOrder || diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 9e22bf695..f4348f019 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -60,7 +60,10 @@ export const duplicateTemplate = async ({ if (template.templateMeta) { templateMeta = { - create: omit(template.templateMeta, ['id', 'templateId']), + create: { + ...omit(template.templateMeta, ['id', 'templateId']), + emailSettings: template.templateMeta.emailSettings || undefined, + }, }; } diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 27328d6c3..a978120a7 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -54,6 +54,7 @@ export const findTemplates = async ({ templateMeta: { select: { signingOrder: true, + distributionMethod: true, }, }, directLink: { diff --git a/packages/lib/server-only/template/update-template-settings.ts b/packages/lib/server-only/template/update-template-settings.ts index 22ea56db2..c83e15242 100644 --- a/packages/lib/server-only/template/update-template-settings.ts +++ b/packages/lib/server-only/template/update-template-settings.ts @@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({ }, create: { ...meta, + emailSettings: meta?.emailSettings || undefined, }, update: { ...meta, + emailSettings: meta?.emailSettings || undefined, }, }, }, diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts new file mode 100644 index 000000000..f7ff20f7a --- /dev/null +++ b/packages/lib/types/document-email.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import type { DocumentMeta } from '@documenso/prisma/client'; +import { DocumentDistributionMethod } from '@documenso/prisma/client'; + +export enum DocumentEmailEvents { + RecipientSigningRequest = 'recipientSigningRequest', + RecipientRemoved = 'recipientRemoved', + DocumentPending = 'documentPending', + DocumentCompleted = 'documentCompleted', + DocumentDeleted = 'documentDeleted', +} + +export const ZDocumentEmailSettingsSchema = z + .object({ + recipientSigningRequest: z.boolean().default(true), + recipientRemoved: z.boolean().default(true), + documentPending: z.boolean().default(true), + documentCompleted: z.boolean().default(true), + documentDeleted: z.boolean().default(true), + }) + .strip() + .catch(() => ({ + recipientSigningRequest: true, + recipientRemoved: true, + documentPending: true, + documentCompleted: true, + documentDeleted: true, + })); + +export type TDocumentEmailSettings = z.infer; + +export const extractDerivedDocumentEmailSettings = ( + documentMeta?: DocumentMeta | null, +): TDocumentEmailSettings => { + const emailSettings = ZDocumentEmailSettingsSchema.parse(documentMeta?.emailSettings ?? {}); + + if ( + !documentMeta?.distributionMethod || + documentMeta?.distributionMethod === DocumentDistributionMethod.EMAIL + ) { + return emailSettings; + } + + return { + recipientSigningRequest: false, + recipientRemoved: false, + documentPending: false, + documentCompleted: false, + documentDeleted: false, + }; +}; diff --git a/packages/prisma/migrations/20241107095908_add_document_email_setting/migration.sql b/packages/prisma/migrations/20241107095908_add_document_email_setting/migration.sql new file mode 100644 index 000000000..e5a40adf0 --- /dev/null +++ b/packages/prisma/migrations/20241107095908_add_document_email_setting/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "DocumentDistributionMethod" AS ENUM ('EMAIL', 'NONE'); + +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "distributionMethod" "DocumentDistributionMethod" NOT NULL DEFAULT 'EMAIL', +ADD COLUMN "emailSettings" JSONB; + +-- AlterTable +ALTER TABLE "TemplateMeta" ADD COLUMN "distributionMethod" "DocumentDistributionMethod" NOT NULL DEFAULT 'EMAIL', +ADD COLUMN "emailSettings" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 343ee2988..fea26cb64 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -358,19 +358,26 @@ model DocumentData { Template Template? } +enum DocumentDistributionMethod { + EMAIL + NONE +} + model DocumentMeta { - id String @id @default(cuid()) + id String @id @default(cuid()) subject String? message String? - timezone String? @default("Etc/UTC") @db.Text + timezone String? @default("Etc/UTC") @db.Text password String? - dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text - documentId Int @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text + documentId Int @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) redirectUrl String? - signingOrder DocumentSigningOrder @default(PARALLEL) - typedSignatureEnabled Boolean @default(false) - language String @default("en") + signingOrder DocumentSigningOrder @default(PARALLEL) + typedSignatureEnabled Boolean @default(false) + language String @default("en") + distributionMethod DocumentDistributionMethod @default(EMAIL) + emailSettings Json? } enum ReadStatus { @@ -603,17 +610,19 @@ enum TemplateType { } model TemplateMeta { - id String @id @default(cuid()) - subject String? - message String? - timezone String? @default("Etc/UTC") @db.Text - password String? - dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text - signingOrder DocumentSigningOrder? @default(PARALLEL) - templateId Int @unique - template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) - redirectUrl String? - language String @default("en") + id String @id @default(cuid()) + subject String? + message String? + timezone String? @default("Etc/UTC") @db.Text + password String? + dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text + signingOrder DocumentSigningOrder? @default(PARALLEL) + templateId Int @unique + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) + redirectUrl String? + language String @default("en") + distributionMethod DocumentDistributionMethod @default(EMAIL) + emailSettings Json? } model Template { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 753cd87d0..14e8c8eb7 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -413,7 +413,15 @@ export const documentRouter = router({ try { const { documentId, teamId, meta } = input; - if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) { + if ( + meta.message || + meta.subject || + meta.timezone || + meta.dateFormat || + meta.redirectUrl || + meta.distributionMethod || + meta.emailSettings + ) { await upsertDocumentMeta({ documentId, subject: meta.subject, @@ -421,7 +429,9 @@ export const documentRouter = router({ dateFormat: meta.dateFormat, timezone: meta.timezone, redirectUrl: meta.redirectUrl, + distributionMethod: meta.distributionMethod, userId: ctx.user.id, + emailSettings: meta.emailSettings, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index b906dae21..0b2d1685d 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -5,9 +5,11 @@ import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { + DocumentDistributionMethod, DocumentSigningOrder, DocumentSource, DocumentStatus, @@ -155,6 +157,7 @@ export const ZSendDocumentMutationSchema = z.object({ message: z.string(), timezone: z.string().optional(), dateFormat: z.string().optional(), + distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), redirectUrl: z .string() .optional() @@ -162,6 +165,7 @@ export const ZSendDocumentMutationSchema = z.object({ message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', }), + emailSettings: ZDocumentEmailSettingsSchema.optional(), }), }); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index ddd4a73ad..4cf333ec0 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -120,7 +120,7 @@ export const templateRouter = router({ requestMetadata, }); - if (input.sendDocument) { + if (input.distributeDocument) { document = await sendDocument({ documentId: document.id, userId: ctx.user.id, diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 41847a333..eef77c3b9 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -5,9 +5,14 @@ import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; -import { DocumentSigningOrder, TemplateType } from '@documenso/prisma/client'; +import { + DocumentDistributionMethod, + DocumentSigningOrder, + TemplateType, +} from '@documenso/prisma/client'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; @@ -41,7 +46,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ const emails = recipients.map((signer) => signer.email); return new Set(emails).size === emails.length; }, 'Recipients must have unique emails'), - sendDocument: z.boolean().optional(), + distributeDocument: z.boolean().optional(), }); export const ZDuplicateTemplateMutationSchema = z.object({ @@ -99,6 +104,8 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({ message: z.string(), timezone: z.string(), dateFormat: z.string(), + distributionMethod: z.nativeEnum(DocumentDistributionMethod), + emailSettings: ZDocumentEmailSettingsSchema, redirectUrl: z .string() .optional() diff --git a/packages/ui/components/document/document-email-checkboxes.tsx b/packages/ui/components/document/document-email-checkboxes.tsx new file mode 100644 index 000000000..7242393c4 --- /dev/null +++ b/packages/ui/components/document/document-email-checkboxes.tsx @@ -0,0 +1,222 @@ +import { Trans } from '@lingui/macro'; +import { InfoIcon } from 'lucide-react'; + +import { DocumentEmailEvents } from '@documenso/lib/types/document-email'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +import { cn } from '../../lib/utils'; +import { Checkbox } from '../../primitives/checkbox'; + +type Value = Record; + +type DocumentEmailCheckboxesProps = { + value: Value; + onChange: (value: Value) => void; + className?: string; +}; + +export const DocumentEmailCheckboxes = ({ + value, + onChange, + className, +}: DocumentEmailCheckboxesProps) => { + return ( +
+
+ + onChange({ ...value, [DocumentEmailEvents.RecipientSigningRequest]: Boolean(checked) }) + } + /> + + +
+ +
+ + onChange({ ...value, [DocumentEmailEvents.RecipientRemoved]: Boolean(checked) }) + } + /> + + +
+ +
+ + onChange({ ...value, [DocumentEmailEvents.DocumentPending]: Boolean(checked) }) + } + /> + + +
+ +
+ + onChange({ ...value, [DocumentEmailEvents.DocumentCompleted]: Boolean(checked) }) + } + /> + + +
+ +
+ + onChange({ ...value, [DocumentEmailEvents.DocumentDeleted]: Boolean(checked) }) + } + /> + + +
+
+ ); +}; diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index a2b145641..f98d21a28 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -2,18 +2,32 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; +import { formatSigningLink } from '@documenso/lib/utils/recipients'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { + DocumentDistributionMethod, + DocumentStatus, + RecipientRole, +} from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { CopyTextButton } from '../../components/common/copy-text-button'; +import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes'; +import { AvatarWithText } from '../avatar'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; import { useStep } from '../stepper'; import { Textarea } from '../textarea'; +import { toast } from '../use-toast'; import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types'; import { DocumentFlowFormContainerActions, @@ -42,20 +56,45 @@ export const AddSubjectFormPartial = ({ onSubmit, isDocumentPdfLoaded, }: AddSubjectFormProps) => { + const { _ } = useLingui(); + const { register, handleSubmit, + setValue, + watch, formState: { errors, isSubmitting }, } = useForm({ defaultValues: { meta: { subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', + distributionMethod: + document.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL, + emailSettings: ZDocumentEmailSettingsSchema.parse(document?.documentMeta?.emailSettings), }, }, resolver: zodResolver(ZAddSubjectFormSchema), }); + const GoNextLabel = { + [DocumentDistributionMethod.EMAIL]: { + [DocumentStatus.DRAFT]: msg`Send`, + [DocumentStatus.PENDING]: recipients.some((recipient) => recipient.sendStatus === 'SENT') + ? msg`Resend` + : msg`Send`, + [DocumentStatus.COMPLETED]: msg`Update`, + }, + [DocumentDistributionMethod.NONE]: { + [DocumentStatus.DRAFT]: msg`Generate Links`, + [DocumentStatus.PENDING]: msg`View Document`, + [DocumentStatus.COMPLETED]: msg`View Document`, + }, + }; + + const distributionMethod = watch('meta.distributionMethod'); + const emailSettings = watch('meta.emailSettings'); + const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); @@ -72,46 +111,158 @@ export const AddSubjectFormPartial = ({ ))} -
-
- + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + setValue('meta.distributionMethod', value as DocumentDistributionMethod) + } + value={distributionMethod} + className="mb-2" + > + + + Email + + + None + + + - + + {distributionMethod === DocumentDistributionMethod.EMAIL && ( + +
+ - -
+ -
- + +
-