From efd6531d3e0a726d07a3f539729f2d157d5b805c Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 12 Dec 2024 13:55:46 +1100 Subject: [PATCH] feat: add controls for sending completion emails to document owners Adds a new `ownerDocumentCompleted` to the email settings that controls whether a document will be sent to the owner upon completion. This was previously the only email you couldn't disable and didn't account for users integrating with just the API and Webhooks. Also adds a flag to the public `sendDocument` endpoint which will adjust this setting while sendint the document for users who aren't using `emailSettings` on the `createDocument` endpoint. --- packages/api/v1/implementation.ts | 98 ++++++------- packages/api/v1/schema.ts | 6 +- .../e2e/api/v1/document-sending.spec.ts | 137 ++++++++++++++++++ .../document/send-completed-email.ts | 17 ++- packages/lib/types/document-email.ts | 4 + .../document/document-email-checkboxes.tsx | 43 +++++- 6 files changed, 240 insertions(+), 65 deletions(-) create mode 100644 packages/app-tests/e2e/api/v1/document-sending.spec.ts diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 69ca0f7fe..4b7d866be 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -35,6 +35,7 @@ import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/tem import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta, @@ -637,69 +638,52 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }), sendDocument: authenticatedMiddleware(async (args, user, team) => { - const { id } = args.params; - const { sendEmail = true } = args.body ?? {}; - - const document = await getDocumentById({ - documentId: Number(id), - userId: user.id, - teamId: team?.id, - }); - - if (!document) { - return { - status: 404, - body: { - message: 'Document not found', - }, - }; - } - - if (document.status === DocumentStatus.COMPLETED) { - return { - status: 400, - body: { - message: 'Document is already complete', - }, - }; - } + const { id: documentId } = args.params; + const { sendEmail, sendCompletionEmails } = args.body; try { - // await setRecipientsForDocument({ - // userId: user.id, - // documentId: Number(id), - // recipients: [ - // { - // email: body.signerEmail, - // name: body.signerName ?? '', - // }, - // ], - // }); + const document = await getDocumentById({ + documentId: Number(documentId), + userId: user.id, + teamId: team?.id, + }); - // await setFieldsForDocument({ - // documentId: Number(id), - // userId: user.id, - // fields: body.fields.map((field) => ({ - // signerEmail: body.signerEmail, - // type: field.fieldType, - // pageNumber: field.pageNumber, - // pageX: field.pageX, - // pageY: field.pageY, - // pageWidth: field.pageWidth, - // pageHeight: field.pageHeight, - // })), - // }); + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } - // if (body.emailBody || body.emailSubject) { - // await upsertDocumentMeta({ - // documentId: Number(id), - // subject: body.emailSubject ?? '', - // message: body.emailBody ?? '', - // }); - // } + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already complete', + }, + }; + } + + const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta); + + // Update document email settings if sendCompletionEmails is provided + if (typeof sendCompletionEmails === 'boolean') { + await upsertDocumentMeta({ + documentId: document.id, + userId: user.id, + emailSettings: { + ...emailSettings, + documentCompleted: sendCompletionEmails, + ownerDocumentCompleted: sendCompletionEmails, + }, + requestMetadata: extractNextApiRequestMetadata(args.req), + }); + } const { Recipient: recipients, ...sentDocument } = await sendDocument({ - documentId: Number(id), + documentId: document.id, userId: user.id, teamId: team?.id, sendEmail, diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index fa0276a9d..dcd06cd5b 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -88,8 +88,12 @@ export const ZSendDocumentForSigningMutationSchema = z description: 'Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links.', }), + sendCompletionEmails: z.boolean().optional().openapi({ + description: + 'Whether to send completion emails when the document is fully signed. This will override the document email settings.', + }), }) - .or(z.literal('').transform(() => ({ sendEmail: true }))); + .or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; diff --git a/packages/app-tests/e2e/api/v1/document-sending.spec.ts b/packages/app-tests/e2e/api/v1/document-sending.spec.ts new file mode 100644 index 000000000..81e1e606c --- /dev/null +++ b/packages/app-tests/e2e/api/v1/document-sending.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +test.describe('Document API', () => { + test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => { + const user = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['signer@example.com'], + }); + + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test', + expiresIn: null, + }); + + // Test with sendCompletionEmails: false + const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + sendCompletionEmails: false, + }, + }); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + // Verify email settings were updated + const updatedDocument = await prisma.document.findUnique({ + where: { id: document.id }, + include: { documentMeta: true }, + }); + + expect(updatedDocument?.documentMeta?.emailSettings).toMatchObject({ + documentCompleted: false, + ownerDocumentCompleted: false, + }); + + // Test with sendCompletionEmails: true + const response2 = await request.post( + `${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + sendCompletionEmails: true, + }, + }, + ); + + expect(response2.ok()).toBeTruthy(); + expect(response2.status()).toBe(200); + + // Verify email settings were updated + const updatedDocument2 = await prisma.document.findUnique({ + where: { id: document.id }, + include: { documentMeta: true }, + }); + + expect(updatedDocument2?.documentMeta?.emailSettings ?? {}).toMatchObject({ + documentCompleted: true, + ownerDocumentCompleted: true, + }); + }); + + test('sendDocument: should not modify email settings when sendCompletionEmails is not provided', async ({ + request, + }) => { + const user = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['signer@example.com'], + }); + + // Set initial email settings + await prisma.documentMeta.upsert({ + where: { documentId: document.id }, + create: { + documentId: document.id, + emailSettings: { + documentCompleted: true, + ownerDocumentCompleted: false, + }, + }, + update: { + documentId: document.id, + emailSettings: { + documentCompleted: true, + ownerDocumentCompleted: false, + }, + }, + }); + + const { token } = await createApiToken({ + userId: user.id, + tokenName: 'test', + expiresIn: null, + }); + + const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + sendEmail: true, + }, + }); + + expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); + + // Verify email settings were not modified + const updatedDocument = await prisma.document.findUnique({ + where: { id: document.id }, + include: { documentMeta: true }, + }); + + expect(updatedDocument?.documentMeta?.emailSettings ?? {}).toMatchObject({ + documentCompleted: true, + ownerDocumentCompleted: false, + }); + }); +}); diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 34ae79c0b..845e6551d 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -72,14 +72,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo const i18n = await getI18nInstance(document.documentMeta?.language); - const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings( - document.documentMeta, - ).documentCompleted; + const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta); + const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted; + const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted; - // If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately. + // Send email to document owner if: + // 1. Owner document completed emails are enabled AND + // 2. Either: + // - The owner is not a recipient, OR + // - Recipient emails are disabled if ( - !document.Recipient.find((recipient) => recipient.email === owner.email) || - !isDocumentCompletedEmailEnabled + isOwnerDocumentCompletedEmailEnabled && + (!document.Recipient.find((recipient) => recipient.email === owner.email) || + !isDocumentCompletedEmailEnabled) ) { const template = createElement(DocumentCompletedEmailTemplate, { documentName: document.title, diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts index f7ff20f7a..b417b3924 100644 --- a/packages/lib/types/document-email.ts +++ b/packages/lib/types/document-email.ts @@ -9,6 +9,7 @@ export enum DocumentEmailEvents { DocumentPending = 'documentPending', DocumentCompleted = 'documentCompleted', DocumentDeleted = 'documentDeleted', + OwnerDocumentCompleted = 'ownerDocumentCompleted', } export const ZDocumentEmailSettingsSchema = z @@ -18,6 +19,7 @@ export const ZDocumentEmailSettingsSchema = z documentPending: z.boolean().default(true), documentCompleted: z.boolean().default(true), documentDeleted: z.boolean().default(true), + ownerDocumentCompleted: z.boolean().default(true), }) .strip() .catch(() => ({ @@ -26,6 +28,7 @@ export const ZDocumentEmailSettingsSchema = z documentPending: true, documentCompleted: true, documentDeleted: true, + ownerDocumentCompleted: true, })); export type TDocumentEmailSettings = z.infer; @@ -48,5 +51,6 @@ export const extractDerivedDocumentEmailSettings = ( documentPending: false, documentCompleted: false, documentDeleted: false, + ownerDocumentCompleted: emailSettings.ownerDocumentCompleted, }; }; diff --git a/packages/ui/components/document/document-email-checkboxes.tsx b/packages/ui/components/document/document-email-checkboxes.tsx index 7242393c4..9ec1abdcd 100644 --- a/packages/ui/components/document/document-email-checkboxes.tsx +++ b/packages/ui/components/document/document-email-checkboxes.tsx @@ -1,13 +1,14 @@ import { Trans } from '@lingui/macro'; import { InfoIcon } from 'lucide-react'; +import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email'; 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 Value = TDocumentEmailSettings; type DocumentEmailCheckboxesProps = { value: Value; @@ -217,6 +218,46 @@ export const DocumentEmailCheckboxes = ({ + +
+ + onChange({ ...value, [DocumentEmailEvents.OwnerDocumentCompleted]: Boolean(checked) }) + } + /> + + +
); };