feat: notify owner when a recipient signs (#1549)

This commit is contained in:
Ephraim Duncan
2024-12-26 11:04:13 +00:00
committed by GitHub
parent c588c09b26
commit a1a2d0801b
7 changed files with 311 additions and 2 deletions

View File

@ -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 (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="text-base font-semibold text-[#7AC455]">
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
/>
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
<Trans>
{recipientReference} has signed "{documentName}"
</Trans>
</Text>
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
<Trans>{recipientReference} has completed signing the document.</Trans>
</Text>
</Section>
</>
);
};
export default TemplateDocumentRecipientSigned;

View File

@ -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 (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateDocumentRecipientSigned
documentName={documentName}
recipientName={recipientName}
recipientEmail={recipientEmail}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Html>
);
};
export default DocumentRecipientSignedEmailTemplate;

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,45 @@ export const DocumentEmailCheckboxes = ({
}: DocumentEmailCheckboxesProps) => {
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientSigned}
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={value.recipientSigned}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientSigned]: Boolean(checked) })
}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={DocumentEmailEvents.RecipientSigned}
>
<Trans>Send recipient signed 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 signed email</Trans>
</strong>
</h2>
<p>
<Trans>
This email is sent to the document owner when a recipient has signed the document.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.RecipientSigningRequest}
@ -244,7 +283,7 @@ export const DocumentEmailCheckboxes = ({
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Document completed email to the owner</Trans>
<Trans>Document completed email</Trans>
</strong>
</h2>