diff --git a/packages/email/template-components/template-document-recipient-signed.tsx b/packages/email/template-components/template-document-recipient-signed.tsx new file mode 100644 index 000000000..88d6bb1d4 --- /dev/null +++ b/packages/email/template-components/template-document-recipient-signed.tsx @@ -0,0 +1,56 @@ +import { Trans } from '@lingui/macro'; + +import { Column, Img, Section, Text } from '../components'; +import { TemplateDocumentImage } from './template-document-image'; + +export interface TemplateDocumentRecipientSignedProps { + documentName: string; + recipientName: string; + recipientEmail: string; + assetBaseUrl: string; +} + +export const TemplateDocumentRecipientSigned = ({ + documentName, + recipientName, + recipientEmail, + assetBaseUrl, +}: TemplateDocumentRecipientSignedProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + const recipientReference = recipientName || recipientEmail; + + return ( + <> + + +
+
+ + + + Completed + + +
+ + + + {recipientReference} has signed "{documentName}" + + + + + {recipientReference} has completed signing the document. + +
+ + ); +}; + +export default TemplateDocumentRecipientSigned; diff --git a/packages/email/templates/document-recipient-signed.tsx b/packages/email/templates/document-recipient-signed.tsx new file mode 100644 index 000000000..36d0c78c1 --- /dev/null +++ b/packages/email/templates/document-recipient-signed.tsx @@ -0,0 +1,70 @@ +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Html, Img, Preview, Section } from '../components'; +import { useBranding } from '../providers/branding'; +import { TemplateDocumentRecipientSigned } from '../template-components/template-document-recipient-signed'; +import { TemplateFooter } from '../template-components/template-footer'; + +export interface DocumentRecipientSignedEmailTemplateProps { + documentName?: string; + recipientName?: string; + recipientEmail?: string; + assetBaseUrl?: string; +} + +export const DocumentRecipientSignedEmailTemplate = ({ + documentName = 'Open Source Pledge.pdf', + recipientName = 'John Doe', + recipientEmail = 'lucas@documenso.com', + assetBaseUrl = 'http://localhost:3002', +}: DocumentRecipientSignedEmailTemplateProps) => { + const { _ } = useLingui(); + const branding = useBranding(); + + const recipientReference = recipientName || recipientEmail; + + const previewText = msg`${recipientReference} has signed ${documentName}`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+ + + + +
+ + + ); +}; + +export default DocumentRecipientSignedEmailTemplate; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 366c2f517..988208a0d 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,5 +1,6 @@ import { JobClient } from './client/client'; import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; +import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email'; import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails'; import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; @@ -19,6 +20,7 @@ export const jobsClient = new JobClient([ SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION, SEAL_DOCUMENT_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, + SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts new file mode 100644 index 000000000..7fff47395 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts @@ -0,0 +1,130 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/macro'; +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed'; +import { prisma } from '@documenso/prisma'; + +import { getI18nInstance } from '../../../client-only/providers/i18n.server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding'; +import { type JobDefinition } from '../../client/_internal/job'; + +const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.signed.email'; + +const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + documentId: z.number(), + recipientId: z.number(), +}); + +export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = { + id: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Recipient Signed Email', + version: '1.0.0', + trigger: { + name: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const { documentId, recipientId } = payload; + + const document = await prisma.document.findFirst({ + where: { + id: documentId, + Recipient: { + some: { + id: recipientId, + }, + }, + }, + include: { + Recipient: { + where: { + id: recipientId, + }, + }, + User: true, + documentMeta: true, + team: { + include: { + teamGlobalSettings: true, + }, + }, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + if (document.Recipient.length === 0) { + throw new Error('Document has no recipients'); + } + + const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientSigned; + + if (!isRecipientSignedEmailEnabled) { + return; + } + + const [recipient] = document.Recipient; + const { email: recipientEmail, name: recipientName } = recipient; + const { User: owner } = document; + + const recipientReference = recipientName || recipientEmail; + + // Don't send notification if the owner is the one who signed + if (owner.email === recipientEmail) { + return; + } + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const i18n = await getI18nInstance(document.documentMeta?.language); + + const template = createElement(DocumentRecipientSignedEmailTemplate, { + documentName: document.title, + recipientName, + recipientEmail, + assetBaseUrl, + }); + + await io.runTask('send-recipient-signed-email', async () => { + const branding = document.team?.teamGlobalSettings + ? teamGlobalSettingsToBranding(document.team.teamGlobalSettings) + : undefined; + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }), + renderEmailWithI18N(template, { + lang: document.documentMeta?.language, + branding, + plainText: true, + }), + ]); + + await mailer.sendMail({ + to: { + name: owner.name ?? '', + address: owner.email, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: i18n._(msg`${recipientReference} has signed "${document.title}"`), + html, + text, + }); + }); + }, +} as const satisfies JobDefinition< + typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID, + z.infer +>; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 09e21b964..3f3338b13 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -8,8 +8,8 @@ import { RecipientRole, SendStatus, SigningStatus, + WebhookTriggerEvents, } from '@documenso/prisma/client'; -import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { jobs } from '../../jobs/client'; import type { TRecipientActionAuth } from '../../types/document-auth'; @@ -139,6 +139,14 @@ export const completeDocumentWithToken = async ({ }); }); + await jobs.triggerJob({ + name: 'send.recipient.signed.email', + payload: { + documentId: document.id, + recipientId: recipient.id, + }, + }); + const pendingRecipients = await prisma.recipient.findMany({ select: { id: true, diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts index b417b3924..7a0e07305 100644 --- a/packages/lib/types/document-email.ts +++ b/packages/lib/types/document-email.ts @@ -6,6 +6,7 @@ import { DocumentDistributionMethod } from '@documenso/prisma/client'; export enum DocumentEmailEvents { RecipientSigningRequest = 'recipientSigningRequest', RecipientRemoved = 'recipientRemoved', + RecipientSigned = 'recipientSigned', DocumentPending = 'documentPending', DocumentCompleted = 'documentCompleted', DocumentDeleted = 'documentDeleted', @@ -16,6 +17,7 @@ export const ZDocumentEmailSettingsSchema = z .object({ recipientSigningRequest: z.boolean().default(true), recipientRemoved: z.boolean().default(true), + recipientSigned: z.boolean().default(true), documentPending: z.boolean().default(true), documentCompleted: z.boolean().default(true), documentDeleted: z.boolean().default(true), @@ -25,6 +27,7 @@ export const ZDocumentEmailSettingsSchema = z .catch(() => ({ recipientSigningRequest: true, recipientRemoved: true, + recipientSigned: true, documentPending: true, documentCompleted: true, documentDeleted: true, @@ -48,6 +51,7 @@ export const extractDerivedDocumentEmailSettings = ( return { recipientSigningRequest: false, recipientRemoved: false, + recipientSigned: false, documentPending: false, documentCompleted: false, documentDeleted: false, diff --git a/packages/ui/components/document/document-email-checkboxes.tsx b/packages/ui/components/document/document-email-checkboxes.tsx index 9ec1abdcd..5da263dc4 100644 --- a/packages/ui/components/document/document-email-checkboxes.tsx +++ b/packages/ui/components/document/document-email-checkboxes.tsx @@ -23,6 +23,45 @@ export const DocumentEmailCheckboxes = ({ }: DocumentEmailCheckboxesProps) => { return (
+
+ + onChange({ ...value, [DocumentEmailEvents.RecipientSigned]: Boolean(checked) }) + } + /> + + +
+

- Document completed email to the owner + Document completed email