mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
feat: notify owner when a recipient signs (#1549)
This commit is contained in:
@ -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;
|
||||||
70
packages/email/templates/document-recipient-signed.tsx
Normal file
70
packages/email/templates/document-recipient-signed.tsx
Normal 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;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { JobClient } from './client/client';
|
import { JobClient } from './client/client';
|
||||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
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_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
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';
|
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,
|
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||||
|
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@ -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>
|
||||||
|
>;
|
||||||
@ -8,8 +8,8 @@ import {
|
|||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
|
WebhookTriggerEvents,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
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({
|
const pendingRecipients = await prisma.recipient.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { DocumentDistributionMethod } from '@documenso/prisma/client';
|
|||||||
export enum DocumentEmailEvents {
|
export enum DocumentEmailEvents {
|
||||||
RecipientSigningRequest = 'recipientSigningRequest',
|
RecipientSigningRequest = 'recipientSigningRequest',
|
||||||
RecipientRemoved = 'recipientRemoved',
|
RecipientRemoved = 'recipientRemoved',
|
||||||
|
RecipientSigned = 'recipientSigned',
|
||||||
DocumentPending = 'documentPending',
|
DocumentPending = 'documentPending',
|
||||||
DocumentCompleted = 'documentCompleted',
|
DocumentCompleted = 'documentCompleted',
|
||||||
DocumentDeleted = 'documentDeleted',
|
DocumentDeleted = 'documentDeleted',
|
||||||
@ -16,6 +17,7 @@ export const ZDocumentEmailSettingsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
recipientSigningRequest: z.boolean().default(true),
|
recipientSigningRequest: z.boolean().default(true),
|
||||||
recipientRemoved: z.boolean().default(true),
|
recipientRemoved: z.boolean().default(true),
|
||||||
|
recipientSigned: z.boolean().default(true),
|
||||||
documentPending: z.boolean().default(true),
|
documentPending: z.boolean().default(true),
|
||||||
documentCompleted: z.boolean().default(true),
|
documentCompleted: z.boolean().default(true),
|
||||||
documentDeleted: z.boolean().default(true),
|
documentDeleted: z.boolean().default(true),
|
||||||
@ -25,6 +27,7 @@ export const ZDocumentEmailSettingsSchema = z
|
|||||||
.catch(() => ({
|
.catch(() => ({
|
||||||
recipientSigningRequest: true,
|
recipientSigningRequest: true,
|
||||||
recipientRemoved: true,
|
recipientRemoved: true,
|
||||||
|
recipientSigned: true,
|
||||||
documentPending: true,
|
documentPending: true,
|
||||||
documentCompleted: true,
|
documentCompleted: true,
|
||||||
documentDeleted: true,
|
documentDeleted: true,
|
||||||
@ -48,6 +51,7 @@ export const extractDerivedDocumentEmailSettings = (
|
|||||||
return {
|
return {
|
||||||
recipientSigningRequest: false,
|
recipientSigningRequest: false,
|
||||||
recipientRemoved: false,
|
recipientRemoved: false,
|
||||||
|
recipientSigned: false,
|
||||||
documentPending: false,
|
documentPending: false,
|
||||||
documentCompleted: false,
|
documentCompleted: false,
|
||||||
documentDeleted: false,
|
documentDeleted: false,
|
||||||
|
|||||||
@ -23,6 +23,45 @@ export const DocumentEmailCheckboxes = ({
|
|||||||
}: DocumentEmailCheckboxesProps) => {
|
}: DocumentEmailCheckboxesProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)}>
|
<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">
|
<div className="flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={DocumentEmailEvents.RecipientSigningRequest}
|
id={DocumentEmailEvents.RecipientSigningRequest}
|
||||||
@ -244,7 +283,7 @@ export const DocumentEmailCheckboxes = ({
|
|||||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||||
<h2>
|
<h2>
|
||||||
<strong>
|
<strong>
|
||||||
<Trans>Document completed email to the owner</Trans>
|
<Trans>Document completed email</Trans>
|
||||||
</strong>
|
</strong>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user