feat: add document distribution setting (#1437)

Add a document distribution setting which will allow us to further
configure how recipients currently receive documents.
This commit is contained in:
David Nguyen
2024-11-08 13:32:13 +09:00
committed by GitHub
parent 451723a8ab
commit f6bcf921d5
32 changed files with 911 additions and 160 deletions

View File

@ -12,6 +12,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -177,8 +178,8 @@ export const EditDocumentForm = ({
stepIndex: 3, stepIndex: 3,
}, },
subject: { subject: {
title: msg`Add Subject`, title: msg`Distribute Document`,
description: msg`Add the subject and message you wish to send to signers.`, description: msg`Choose how the document will reach recipients`,
stepIndex: 4, stepIndex: 4,
}, },
}; };
@ -307,7 +308,7 @@ export const EditDocumentForm = ({
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.meta; const { subject, message, distributionMethod, emailSettings } = data.meta;
try { try {
await sendDocument({ await sendDocument({
@ -316,9 +317,12 @@ export const EditDocumentForm = ({
meta: { meta: {
subject, subject,
message, message,
distributionMethod,
emailSettings,
}, },
}); });
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({ toast({
title: _(msg`Document sent`), title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`), description: _(msg`Your document has been sent successfully.`),
@ -326,6 +330,18 @@ export const EditDocumentForm = ({
}); });
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) { } catch (err) {
console.error(err); console.error(err);

View File

@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
<UseTemplateDialog <UseTemplateDialog
templateId={row.original.id} templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder} templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.Recipient} recipients={row.original.Recipient}
documentRootPath={documentRootPath} documentRootPath={documentRootPath}
/> />

View File

@ -17,7 +17,7 @@ import {
} from '@documenso/lib/constants/template'; } from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client'; 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 { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -49,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z const ZAddRecipientsForNewDocumentSchema = z
.object({ .object({
sendDocument: z.boolean(), distributeDocument: z.boolean(),
recipients: z.array( recipients: z.array(
z.object({ z.object({
id: z.number(), id: z.number(),
@ -93,12 +93,14 @@ export type UseTemplateDialogProps = {
templateId: number; templateId: number;
templateSigningOrder?: DocumentSigningOrder | null; templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[]; recipients: Recipient[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string; documentRootPath: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };
export function UseTemplateDialog({ export function UseTemplateDialog({
recipients, recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath, documentRootPath,
templateId, templateId,
templateSigningOrder, templateSigningOrder,
@ -116,7 +118,7 @@ export function UseTemplateDialog({
const form = useForm<TAddRecipientsForNewDocumentSchema>({ const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: { defaultValues: {
sendDocument: false, distributeDocument: false,
recipients: recipients recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => { .map((recipient) => {
@ -147,7 +149,7 @@ export function UseTemplateDialog({
templateId, templateId,
teamId: team?.id, teamId: team?.id,
recipients: data.recipients, recipients: data.recipients,
sendDocument: data.sendDocument, distributeDocument: data.distributeDocument,
}); });
toast({ toast({
@ -156,7 +158,16 @@ export function UseTemplateDialog({
duration: 5000, 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) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
@ -295,21 +306,22 @@ export function UseTemplateDialog({
<div className="mt-4 flex flex-row items-center"> <div className="mt-4 flex flex-row items-center">
<FormField <FormField
control={form.control} control={form.control}
name="sendDocument" name="distributeDocument"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
id="sendDocument" id="distributeDocument"
className="h-5 w-5" className="h-5 w-5"
checkClassName="dark:text-white text-primary" checkClassName="dark:text-white text-primary"
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
/> />
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label <label
className="text-muted-foreground ml-2 flex items-center text-sm" className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument" htmlFor="distributeDocument"
> >
<Trans>Send document</Trans> <Trans>Send document</Trans>
<Tooltip> <Tooltip>
@ -320,18 +332,50 @@ export function UseTemplateDialog({
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4"> <TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p> <p>
<Trans> <Trans>
{' '}
The document will be immediately sent to recipients if this is The document will be immediately sent to recipients if this is
checked. checked.
</Trans> </Trans>
</p> </p>
<p> <p>
<Trans>Otherwise, the document will be created as a draft.</Trans> <Trans>
Otherwise, the document will be created as a draft.
</Trans>
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</label> </label>
)}
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
</p>
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to
the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
</div> </div>
</FormItem> </FormItem>
)} )}
@ -347,10 +391,12 @@ export function UseTemplateDialog({
</DialogClose> </DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? ( {!form.getValues('distributeDocument') ? (
<Trans>Create as draft</Trans>
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Create and send</Trans> <Trans>Create and send</Trans>
) : ( ) : (
<Trans>Create as draft</Trans> <Trans>Create signing links</Trans>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -106,7 +106,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send // 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.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents'); 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(); await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send // 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.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents'); 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(); await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send // 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.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents'); 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 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.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents'); await page.waitForURL('/documents');

View File

@ -1,7 +1,7 @@
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
export const DOCUMENT_STATUS: { export const DOCUMENT_STATUS: {
[status in DocumentStatus]: { description: MessageDescriptor }; [status in DocumentStatus]: { description: MessageDescriptor };
@ -16,3 +16,19 @@ export const DOCUMENT_STATUS: {
description: msg`Pending`, description: msg`Pending`,
}, },
}; };
type DocumentDistributionMethodTypeData = {
value: DocumentDistributionMethod;
description: MessageDescriptor;
};
export const DOCUMENT_DISTRIBUTION_METHODS: Record<string, DocumentDistributionMethodTypeData> = {
[DocumentDistributionMethod.EMAIL]: {
value: DocumentDistributionMethod.EMAIL,
description: msg`Email`,
},
[DocumentDistributionMethod.NONE]: {
value: DocumentDistributionMethod.NONE,
description: msg`None`,
},
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;

View File

@ -21,6 +21,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles'; } from '../../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
@ -81,6 +82,14 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
return; return;
} }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const customEmail = document?.documentMeta; const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK; const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null; const isTeamDocument = document.teamId !== null;

View File

@ -7,9 +7,10 @@ import {
diffDocumentMetaChanges, diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs'; } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; 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 { SupportedLanguageCodes } from '../../constants/i18n';
import type { TDocumentEmailSettings } from '../../types/document-email';
export type CreateDocumentMetaOptions = { export type CreateDocumentMetaOptions = {
documentId: number; documentId: number;
@ -19,7 +20,9 @@ export type CreateDocumentMetaOptions = {
password?: string; password?: string;
dateFormat?: string; dateFormat?: string;
redirectUrl?: string; redirectUrl?: string;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder; signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
userId: number; userId: number;
@ -36,6 +39,8 @@ export const upsertDocumentMeta = async ({
userId, userId,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
requestMetadata, requestMetadata,
@ -88,6 +93,8 @@ export const upsertDocumentMeta = async ({
documentId, documentId,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
}, },
@ -99,6 +106,8 @@ export const upsertDocumentMeta = async ({
timezone, timezone,
redirectUrl, redirectUrl,
signingOrder, signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled, typedSignatureEnabled,
language, language,
}, },

View File

@ -14,6 +14,7 @@ import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; 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 type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
@ -178,6 +179,14 @@ const handleDocumentOwnerDelete = async ({
}); });
}); });
const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeleteEmailEnabled) {
return deletedDocument;
}
// Send cancellation emails to recipients. // Send cancellation emails to recipients.
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {

View File

@ -19,6 +19,7 @@ import type { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getDocumentWhereInput } from './get-document-by-id'; import { getDocumentWhereInput } from './get-document-by-id';
@ -89,6 +90,14 @@ export const resendDocument = async ({
throw new Error('Can not send completed document'); throw new Error('Can not send completed document');
} }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) { if (recipient.role === RecipientRole.CC) {

View File

@ -10,6 +10,7 @@ import { DocumentSource } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; 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 type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; 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); const i18n = await getI18nInstance(document.documentMeta?.language);
// If the document owner is not a recipient then send the email to them separately const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) { 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, { const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title, documentName: document.title,
assetBaseUrl, assetBaseUrl,
@ -119,6 +127,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}); });
} }
if (!isDocumentCompletedEmailEnabled) {
return;
}
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
const customEmailTemplate = { const customEmailTemplate = {

View File

@ -8,6 +8,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendDeleteEmailOptions { export interface SendDeleteEmailOptions {
@ -22,6 +23,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
}, },
include: { include: {
User: true, User: true,
documentMeta: true,
}, },
}); });
@ -29,6 +31,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
throw new Error('Document not found'); throw new Error('Document not found');
} }
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeletedEmailEnabled) {
return;
}
const { email, name } = document.User; const { email, name } = document.User;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';

View File

@ -13,6 +13,7 @@ import {
import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -29,7 +30,7 @@ export const sendDocument = async ({
documentId, documentId,
userId, userId,
teamId, teamId,
sendEmail = true, sendEmail,
requestMetadata, requestMetadata,
}: SendDocumentOptions) => { }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({ 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.'); // 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( await Promise.all(
recipientsToNotify.map(async (recipient) => { recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {

View File

@ -8,6 +8,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendPendingEmailOptions { export interface SendPendingEmailOptions {
@ -43,6 +44,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients'); throw new Error('Document has no recipients');
} }
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
if (!isDocumentPendingEmailEnabled) {
return;
}
const [recipient] = document.Recipient; const [recipient] = document.Recipient;
const { email, name } = recipient; const { email, name } = recipient;

View File

@ -13,6 +13,7 @@ import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; 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 type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
@ -40,8 +41,16 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
const { status, User: user } = document; const { status, User: user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
// if the document is pending, send cancellation emails to all recipients // 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( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) { if (recipient.sendStatus !== SendStatus.SENT) {

View File

@ -26,6 +26,7 @@ import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients'; import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; 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. // Send emails to deleted recipients.
await Promise.all( await Promise.all(
removedRecipients.map(async (recipient) => { removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) { if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
return; return;
} }

View File

@ -275,6 +275,7 @@ export const createDocumentFromDirectTemplate = async ({
subject: metaEmailSubject, subject: metaEmailSubject,
language: metaLanguage, language: metaLanguage,
signingOrder: metaSigningOrder, signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
}, },
}, },
}, },

View File

@ -1,5 +1,6 @@
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
import { import {
DocumentSigningOrder, DocumentSigningOrder,
DocumentSource, DocumentSource,
@ -62,6 +63,7 @@ export type CreateDocumentFromTemplateOptions = {
redirectUrl?: string; redirectUrl?: string;
signingOrder?: DocumentSigningOrder; signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
}; };
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
@ -177,6 +179,9 @@ export const createDocumentFromTemplate = async ({
password: override?.password || template.templateMeta?.password, password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat, dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl, redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
emailSettings: template.templateMeta?.emailSettings || undefined,
signingOrder: signingOrder:
override?.signingOrder || override?.signingOrder ||
template.templateMeta?.signingOrder || template.templateMeta?.signingOrder ||

View File

@ -60,7 +60,10 @@ export const duplicateTemplate = async ({
if (template.templateMeta) { if (template.templateMeta) {
templateMeta = { templateMeta = {
create: omit(template.templateMeta, ['id', 'templateId']), create: {
...omit(template.templateMeta, ['id', 'templateId']),
emailSettings: template.templateMeta.emailSettings || undefined,
},
}; };
} }

View File

@ -54,6 +54,7 @@ export const findTemplates = async ({
templateMeta: { templateMeta: {
select: { select: {
signingOrder: true, signingOrder: true,
distributionMethod: true,
}, },
}, },
directLink: { directLink: {

View File

@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
}, },
create: { create: {
...meta, ...meta,
emailSettings: meta?.emailSettings || undefined,
}, },
update: { update: {
...meta, ...meta,
emailSettings: meta?.emailSettings || undefined,
}, },
}, },
}, },

View File

@ -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<typeof ZDocumentEmailSettingsSchema>;
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,
};
};

View File

@ -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;

View File

@ -358,6 +358,11 @@ model DocumentData {
Template Template? Template Template?
} }
enum DocumentDistributionMethod {
EMAIL
NONE
}
model DocumentMeta { model DocumentMeta {
id String @id @default(cuid()) id String @id @default(cuid())
subject String? subject String?
@ -371,6 +376,8 @@ model DocumentMeta {
signingOrder DocumentSigningOrder @default(PARALLEL) signingOrder DocumentSigningOrder @default(PARALLEL)
typedSignatureEnabled Boolean @default(false) typedSignatureEnabled Boolean @default(false)
language String @default("en") language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json?
} }
enum ReadStatus { enum ReadStatus {
@ -614,6 +621,8 @@ model TemplateMeta {
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String? redirectUrl String?
language String @default("en") language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json?
} }
model Template { model Template {

View File

@ -413,7 +413,15 @@ export const documentRouter = router({
try { try {
const { documentId, teamId, meta } = input; 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({ await upsertDocumentMeta({
documentId, documentId,
subject: meta.subject, subject: meta.subject,
@ -421,7 +429,9 @@ export const documentRouter = router({
dateFormat: meta.dateFormat, dateFormat: meta.dateFormat,
timezone: meta.timezone, timezone: meta.timezone,
redirectUrl: meta.redirectUrl, redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
userId: ctx.user.id, userId: ctx.user.id,
emailSettings: meta.emailSettings,
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} }

View File

@ -5,9 +5,11 @@ import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema, ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { import {
DocumentDistributionMethod,
DocumentSigningOrder, DocumentSigningOrder,
DocumentSource, DocumentSource,
DocumentStatus, DocumentStatus,
@ -155,6 +157,7 @@ export const ZSendDocumentMutationSchema = z.object({
message: z.string(), message: z.string(),
timezone: z.string().optional(), timezone: z.string().optional(),
dateFormat: z.string().optional(), dateFormat: z.string().optional(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
redirectUrl: z redirectUrl: z
.string() .string()
.optional() .optional()
@ -162,6 +165,7 @@ export const ZSendDocumentMutationSchema = z.object({
message: message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.', 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}), }),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
}), }),
}); });

View File

@ -120,7 +120,7 @@ export const templateRouter = router({
requestMetadata, requestMetadata,
}); });
if (input.sendDocument) { if (input.distributeDocument) {
document = await sendDocument({ document = await sendDocument({
documentId: document.id, documentId: document.id,
userId: ctx.user.id, userId: ctx.user.id,

View File

@ -5,9 +5,14 @@ import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema, ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; 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'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
@ -41,7 +46,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
const emails = recipients.map((signer) => signer.email); const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length; return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'), }, 'Recipients must have unique emails'),
sendDocument: z.boolean().optional(), distributeDocument: z.boolean().optional(),
}); });
export const ZDuplicateTemplateMutationSchema = z.object({ export const ZDuplicateTemplateMutationSchema = z.object({
@ -99,6 +104,8 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
message: z.string(), message: z.string(),
timezone: z.string(), timezone: z.string(),
dateFormat: z.string(), dateFormat: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
emailSettings: ZDocumentEmailSettingsSchema,
redirectUrl: z redirectUrl: z
.string() .string()
.optional() .optional()

View File

@ -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<DocumentEmailEvents, boolean>;
type DocumentEmailCheckboxesProps = {
value: Value;
onChange: (value: Value) => void;
className?: string;
};
export const DocumentEmailCheckboxes = ({
value,
onChange,
className,
}: DocumentEmailCheckboxesProps) => {
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientSigningRequest}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.recipientSigningRequest}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientSigningRequest]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
>
<Trans>Send recipient signing request email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Recipient signing request email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the recipient requesting them to sign the document.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientRemoved}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.recipientRemoved}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientRemoved]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.RecipientRemoved}
>
<Trans>Send recipient removed email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Recipient removed email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the recipient if they are removed from a pending document.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentPending}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentPending}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentPending]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentPending}
>
<Trans>Send document pending email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document pending email</Trans>
</strong>
</h2>
<p>
<Trans>
This email will be sent to the recipient who has just signed the document, if
there are still other recipients who have not signed yet.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentCompleted}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentCompleted}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentCompleted]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentCompleted}
>
<Trans>Send document completed email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document completed email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to all recipients once the document has been fully completed.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.DocumentDeleted}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.documentDeleted}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentDeleted]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.DocumentDeleted}
>
<Trans>Send document deleted email</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document deleted email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to all recipients if a pending document has been deleted.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</div>
);
};

View File

@ -2,18 +2,32 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form'; 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 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 type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; 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 { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input'; import { Input } from '../input';
import { Label } from '../label'; import { Label } from '../label';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Textarea } from '../textarea'; import { Textarea } from '../textarea';
import { toast } from '../use-toast';
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types'; import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
@ -42,20 +56,45 @@ export const AddSubjectFormPartial = ({
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { _ } = useLingui();
const { const {
register, register,
handleSubmit, handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({ } = useForm<TAddSubjectFormSchema>({
defaultValues: { defaultValues: {
meta: { meta: {
subject: document.documentMeta?.subject ?? '', subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '', message: document.documentMeta?.message ?? '',
distributionMethod:
document.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: ZDocumentEmailSettingsSchema.parse(document?.documentMeta?.emailSettings),
}, },
}, },
resolver: zodResolver(ZAddSubjectFormSchema), 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 onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
@ -72,7 +111,33 @@ export const AddSubjectFormPartial = ({
<ShowFieldItem key={index} field={field} recipients={recipients} /> <ShowFieldItem key={index} field={field} recipients={recipients} />
))} ))}
<div className="flex flex-col gap-y-4"> <Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
}
value={distributionMethod}
className="mb-2"
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
</TabsTrigger>
</TabsList>
</Tabs>
<AnimatePresence mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div
key={'Emails'}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="flex flex-col gap-y-4 rounded-lg border p-4"
>
<div> <div>
<Label htmlFor="subject"> <Label htmlFor="subject">
<Trans> <Trans>
@ -106,12 +171,98 @@ export const AddSubjectFormPartial = ({
<FormErrorMessage <FormErrorMessage
className="mt-2" className="mt-2"
error={typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined} error={
typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined
}
/> />
</div> </div>
<DocumentSendEmailMessageHelper /> <DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
className="mt-2"
value={emailSettings}
onChange={(value) => setValue('meta.emailSettings', value)}
/>
</motion.div>
)}
{distributionMethod === DocumentDistributionMethod.NONE && (
<motion.div
key={'Links'}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="rounded-lg border"
>
{document.status === DocumentStatus.DRAFT ? (
<div className="text-muted-foreground py-16 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for with you, which you can send to the
recipients through your method of choice.
</Trans>
</p>
</div> </div>
) : (
<ul className="text-muted-foreground divide-y">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">{recipient.email}</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
@ -121,7 +272,7 @@ export const AddSubjectFormPartial = ({
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? msg`Send` : msg`Update`} goNextLabel={GoNextLabel[distributionMethod][document.status]}
onGoBackClick={previousStep} onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />

View File

@ -1,9 +1,18 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddSubjectFormSchema = z.object({ export const ZAddSubjectFormSchema = z.object({
meta: z.object({ meta: z.object({
subject: z.string(), subject: z.string(),
message: z.string(), message: z.string(),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
emailSettings: ZDocumentEmailSettingsSchema,
}), }),
}); });

View File

@ -4,14 +4,17 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Field, type Recipient } from '@documenso/prisma/client'; import { DocumentDistributionMethod, type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template'; import type { TemplateWithData } from '@documenso/prisma/types/template';
import { import {
DocumentGlobalAuthAccessSelect, DocumentGlobalAuthAccessSelect,
@ -37,6 +40,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import { Combobox } from '../combobox'; import { Combobox } from '../combobox';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
@ -74,6 +78,8 @@ export const AddTemplateSettingsFormPartial = ({
template, template,
onSubmit, onSubmit,
}: AddTemplateSettingsFormProps) => { }: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({ const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions, documentAuth: template.authOptions,
}); });
@ -90,14 +96,20 @@ export const AddTemplateSettingsFormPartial = ({
message: template.templateMeta?.message ?? '', message: template.templateMeta?.message ?? '',
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
distributionMethod:
template.templateMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
redirectUrl: template.templateMeta?.redirectUrl ?? '', redirectUrl: template.templateMeta?.redirectUrl ?? '',
language: template.templateMeta?.language ?? 'en', language: template.templateMeta?.language ?? 'en',
emailSettings: ZDocumentEmailSettingsSchema.parse(template?.templateMeta?.emailSettings),
}, },
}, },
}); });
const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const distributionMethod = form.watch('meta.distributionMethod');
const emailSettings = form.watch('meta.emailSettings');
// We almost always want to set the timezone to the user's local timezone to avoid confusion // We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed. // when the document is signed.
useEffect(() => { useEffect(() => {
@ -198,6 +210,77 @@ export const AddTemplateSettingsFormPartial = ({
)} )}
/> />
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the document is
ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the document to
sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>Links</strong> - We will generate links which you can send to
the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with direct
templates, you will need to manually send the links to the remaining
recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
{isEnterprise && ( {isEnterprise && (
<FormField <FormField
control={form.control} control={form.control}
@ -217,6 +300,7 @@ export const AddTemplateSettingsFormPartial = ({
/> />
)} )}
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple"> <Accordion type="multiple">
<AccordionItem value="email-options" className="border-none"> <AccordionItem value="email-options" className="border-none">
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline"> <AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
@ -266,10 +350,16 @@ export const AddTemplateSettingsFormPartial = ({
/> />
<DocumentSendEmailMessageHelper /> <DocumentSendEmailMessageHelper />
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
)}
<Accordion type="multiple"> <Accordion type="multiple">
<AccordionItem value="advanced-options" className="border-none"> <AccordionItem value="advanced-options" className="border-none">

View File

@ -7,9 +7,11 @@ import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema, ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types'; import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddTemplateSettingsFormSchema = z.object({ export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }), title: z.string().trim().min(1, { message: "Title can't be empty" }),
@ -25,6 +27,10 @@ export const ZAddTemplateSettingsFormSchema = z.object({
message: z.string(), message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
redirectUrl: z redirectUrl: z
.string() .string()
.optional() .optional()
@ -36,6 +42,7 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)]) .union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional() .optional()
.default('en'), .default('en'),
emailSettings: ZDocumentEmailSettingsSchema,
}), }),
}); });