feat(signing): internal "who signed" + completion email alerts to configurable recipients
Adds a per-port `signing_notification_recipients` setting (users / roles / explicit emails via the existing RecipientPicker) and fires a branded internal email on (a) each party signing and (b) full completion — replicating the legacy "Document Signed" / "EOI Complete Update Status" Activepieces flows that staff relied on. - New branded template `signing-status-notification.tsx` (per-signer progress + completion variants, deep-links into the CRM). - New `sendSigningStatusNotification` resolver in document-signing-emails: resolves recipients, falls back to the port reply-to (sales@) when the list is empty so alerts are never silently dropped, per-recipient send. - Wired into `handleRecipientSigned` (first signed transition) and `handleDocumentCompleted` (idempotent, fires once) — reached by both the Documenso webhook and the 5-min poll. Fully guarded so a notification failure never undoes a signing side effect. Respects EMAIL_REDIRECT_TO. - Admin UI: `Document signing alerts` recipients card in settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
This commit is contained in:
@@ -163,6 +163,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'signing_notification_recipients',
|
||||
label: 'Document signing alerts',
|
||||
description:
|
||||
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'eoi_signers',
|
||||
label: 'EOI Signers',
|
||||
|
||||
152
src/lib/email/templates/signing-status-notification.tsx
Normal file
152
src/lib/email/templates/signing-status-notification.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Internal "signing progress" alert — sent to the port's configured
|
||||
* signing-notification recipients (e.g. the admin + sales@) so staff get
|
||||
* a heads-up every time a party signs and again when a document is fully
|
||||
* signed. This is the CRM equivalent of the legacy "Document Signed" /
|
||||
* "EOI Complete Update Status" Activepieces flows.
|
||||
*
|
||||
* Two events:
|
||||
* - `signed` — a single party just signed (carries who + progress).
|
||||
* - `completed` — all parties have signed.
|
||||
*
|
||||
* Unlike the signer-facing templates, this one links back into the CRM
|
||||
* (deep link to the document) rather than to a signing page — the
|
||||
* recipients are staff, not signers.
|
||||
*/
|
||||
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export interface StatusNotificationData {
|
||||
event: 'signed' | 'completed';
|
||||
documentLabel: string;
|
||||
/** Deal / client name for the salutation + subject context. */
|
||||
clientName: string;
|
||||
portName: string;
|
||||
/** Deep link into the CRM document detail page. */
|
||||
crmUrl: string;
|
||||
/** For `signed`: who just signed. */
|
||||
signerName?: string | null;
|
||||
signerRole?: string | null;
|
||||
/** For `signed`: progress within the signing order. */
|
||||
signedCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
function roleLabel(role?: string | null): string {
|
||||
switch (role) {
|
||||
case 'client':
|
||||
return 'the client';
|
||||
case 'developer':
|
||||
return 'the developer';
|
||||
case 'approver':
|
||||
return 'the approver';
|
||||
case 'witness':
|
||||
return 'a witness';
|
||||
default:
|
||||
return 'a signer';
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBody({ data, accent }: { data: StatusNotificationData; accent: string }) {
|
||||
const isCompleted = data.event === 'completed';
|
||||
const progress =
|
||||
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
|
||||
? `${data.signedCount} of ${data.totalCount}`
|
||||
: null;
|
||||
|
||||
const heading = isCompleted
|
||||
? `${data.documentLabel} fully signed`
|
||||
: `${data.signerName ?? 'A signer'} has signed`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
{heading}
|
||||
</Text>
|
||||
{isCompleted ? (
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The {data.documentLabel} for <strong>{data.clientName}</strong> has now been signed by all
|
||||
parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM.
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
<strong>{data.signerName ?? 'A signer'}</strong> ({roleLabel(data.signerRole)}) has signed
|
||||
the {data.documentLabel} for <strong>{data.clientName}</strong>
|
||||
{progress ? ` — ${progress} signatures collected so far.` : '.'}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', margin: '28px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.crmUrl)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
View in CRM
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
|
||||
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
|
||||
Open the deal:{' '}
|
||||
<Link
|
||||
href={safeUrl(data.crmUrl)}
|
||||
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||
>
|
||||
{data.crmUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '13px', color: '#999', lineHeight: '1.5', marginTop: '14px' }}>
|
||||
You're receiving this because you're on the signing-notification list for{' '}
|
||||
{data.portName}. An administrator can change who gets these alerts in CRM settings.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function signingStatusNotificationEmail(
|
||||
data: StatusNotificationData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const isCompleted = data.event === 'completed';
|
||||
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: isCompleted
|
||||
? `${data.documentLabel} fully signed — ${data.clientName}`
|
||||
: `${data.signerName ?? 'A signer'} signed the ${data.documentLabel} — ${data.clientName}`;
|
||||
|
||||
const body = await render(<StatusBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
const progress =
|
||||
typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
|
||||
? ` (${data.signedCount} of ${data.totalCount} signed)`
|
||||
: '';
|
||||
const text = isCompleted
|
||||
? `The ${data.documentLabel} for ${data.clientName} has been signed by all parties and filed in the ${data.portName} CRM.\n\nView in CRM: ${data.crmUrl}`
|
||||
: `${data.signerName ?? 'A signer'} (${roleLabel(data.signerRole)}) has signed the ${data.documentLabel} for ${data.clientName}${progress}.\n\nView in CRM: ${data.crmUrl}`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
signingInvitationEmail,
|
||||
signingReminderEmail,
|
||||
} from '@/lib/email/templates/document-signing';
|
||||
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
||||
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -329,3 +331,78 @@ export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Internal status notifications (staff "who signed" alerts) ────────────────
|
||||
|
||||
export interface SigningStatusNotificationArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
/** `signed` = one party just signed; `completed` = all parties done. */
|
||||
event: 'signed' | 'completed';
|
||||
documentLabel: string;
|
||||
/** Deal / client name for context in the subject + body. */
|
||||
clientName: string;
|
||||
/** Deep link into the CRM document detail page. */
|
||||
crmUrl: string;
|
||||
/** For `signed`: who just signed + their role + running progress. */
|
||||
signerName?: string | null;
|
||||
signerRole?: SignerRole | null;
|
||||
signedCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the port's configured signing-notification recipients (the admin
|
||||
* + sales@, plus any extras) that a party signed or that a document is
|
||||
* fully signed. CRM equivalent of the legacy "Document Signed" /
|
||||
* "EOI Complete Update Status" Activepieces flows.
|
||||
*
|
||||
* Recipients come from the `signing_notification_recipients` setting
|
||||
* (users / roles / emails), falling back to the port's reply-to address
|
||||
* (`email_reply_to`) so the alert is never silently dropped. No-op when
|
||||
* nothing resolves. Per-recipient send so the internal list isn't exposed
|
||||
* across recipients; failures are logged, never thrown (the webhook /
|
||||
* completion path must not be undone by an email hiccup).
|
||||
*/
|
||||
export async function sendSigningStatusNotification(
|
||||
args: SigningStatusNotificationArgs,
|
||||
): Promise<void> {
|
||||
const recipients = await resolveNotificationRecipients(
|
||||
args.portId,
|
||||
'signing_notification_recipients',
|
||||
'email_reply_to',
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const branding = await getBrandingShell(args.portId);
|
||||
const { subject, html, text } = await signingStatusNotificationEmail(
|
||||
{
|
||||
event: args.event,
|
||||
documentLabel: args.documentLabel,
|
||||
clientName: args.clientName,
|
||||
portName: args.portName,
|
||||
crmUrl: args.crmUrl,
|
||||
signerName: args.signerName ?? null,
|
||||
signerRole: args.signerRole ?? null,
|
||||
signedCount: args.signedCount,
|
||||
totalCount: args.totalCount,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
|
||||
const sendLimit = pLimit(3);
|
||||
await Promise.all(
|
||||
recipients.map((to) =>
|
||||
sendLimit(async () => {
|
||||
try {
|
||||
await sendEmail(to, subject, html, undefined, text, args.portId);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, portId: args.portId, recipient: to, event: args.event },
|
||||
'Signing status notification send failed',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
sendSigningInvitation,
|
||||
sendSigningCompleted,
|
||||
sendSigningStatusNotification,
|
||||
type SignerRole,
|
||||
} from '@/lib/services/document-signing-emails.service';
|
||||
import {
|
||||
@@ -1248,6 +1249,14 @@ export async function handleRecipientSigned(eventData: {
|
||||
'cascading "your turn" invite failed after recipient signed',
|
||||
);
|
||||
});
|
||||
|
||||
// Internal "who signed" alert to the port's signing-notification
|
||||
// recipients (admin + sales@). Fire-and-forget + fully guarded inside
|
||||
// the helper so it can't undo the signing that just succeeded.
|
||||
void notifySigningStatus(doc, 'signed', {
|
||||
name: signer.signerName,
|
||||
role: signer.signerRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,6 +1354,70 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the internal "signing progress" alert to the port's configured
|
||||
* signing-notification recipients (admin + sales@, etc). Self-contained
|
||||
* and fully guarded — a notification failure must never undo a signing /
|
||||
* completion side effect, so all errors are swallowed + logged. Resolves
|
||||
* the deal client name + a deep CRM link, and (for `signed`) the running
|
||||
* signed/total progress.
|
||||
*/
|
||||
async function notifySigningStatus(
|
||||
doc: Parameters<typeof resolveDocumentOwner>[1] & {
|
||||
id: string;
|
||||
portId: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
},
|
||||
event: 'signed' | 'completed',
|
||||
signer?: { name: string; role: string | null } | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, doc.portId),
|
||||
columns: { name: true, slug: true },
|
||||
});
|
||||
|
||||
let clientName = doc.title;
|
||||
const owner = await resolveDocumentOwner(doc.portId, doc);
|
||||
if (owner?.entityType === 'client') {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, owner.entityId),
|
||||
columns: { fullName: true },
|
||||
});
|
||||
if (client?.fullName) clientName = client.fullName;
|
||||
}
|
||||
|
||||
let signedCount: number | undefined;
|
||||
let totalCount: number | undefined;
|
||||
if (event === 'signed') {
|
||||
const all = await db
|
||||
.select({ status: documentSigners.status })
|
||||
.from(documentSigners)
|
||||
.where(eq(documentSigners.documentId, doc.id));
|
||||
totalCount = all.length;
|
||||
signedCount = all.filter((s) => s.status === 'signed').length;
|
||||
}
|
||||
|
||||
const crmUrl = `${env.APP_URL ?? ''}/${port?.slug ?? ''}/documents/${doc.id}`;
|
||||
|
||||
await sendSigningStatusNotification({
|
||||
portId: doc.portId,
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
event,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
clientName,
|
||||
crmUrl,
|
||||
signerName: signer?.name ?? null,
|
||||
signerRole: (signer?.role as SignerRole) ?? null,
|
||||
signedCount,
|
||||
totalCount,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, documentId: doc.id, event }, 'signing status notification failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF of a completed document to the
|
||||
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
|
||||
@@ -1924,6 +1997,11 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Internal "fully signed" alert to the port's signing-notification
|
||||
// recipients (admin + sales@). handleDocumentCompleted is idempotent
|
||||
// (early-returns on re-delivery), so this fires exactly once per doc.
|
||||
void notifySigningStatus(doc, 'completed');
|
||||
}
|
||||
|
||||
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {
|
||||
|
||||
50
tests/unit/email/signing-status-notification.test.ts
Normal file
50
tests/unit/email/signing-status-notification.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
|
||||
|
||||
describe('signingStatusNotificationEmail', () => {
|
||||
it('renders a per-signer "has signed" alert with progress + CRM link', async () => {
|
||||
const { subject, html, text } = await signingStatusNotificationEmail({
|
||||
event: 'signed',
|
||||
documentLabel: 'Expression of Interest',
|
||||
clientName: 'Jane Doe',
|
||||
portName: 'Port Nimara',
|
||||
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
|
||||
signerName: 'Jane Doe',
|
||||
signerRole: 'client',
|
||||
signedCount: 1,
|
||||
totalCount: 3,
|
||||
});
|
||||
|
||||
// Subject names who signed + the deal so sales can triage at a glance.
|
||||
expect(subject).toContain('Jane Doe');
|
||||
expect(subject).toContain('signed');
|
||||
// Body states the signing event, the document, and progress.
|
||||
expect(html).toContain('Jane Doe');
|
||||
expect(html).toContain('has signed');
|
||||
expect(html).toContain('Expression of Interest');
|
||||
expect(html).toContain('1 of 3');
|
||||
// Internal recipients get a deep link into the CRM, not a signing link.
|
||||
expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/abc');
|
||||
expect(text).toContain('Jane Doe');
|
||||
expect(text).toContain('1 of 3');
|
||||
});
|
||||
|
||||
it('renders a completion alert when all parties have signed', async () => {
|
||||
const { subject, html, text } = await signingStatusNotificationEmail({
|
||||
event: 'completed',
|
||||
documentLabel: 'Sales Contract',
|
||||
clientName: 'Acme Holdings',
|
||||
portName: 'Port Nimara',
|
||||
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/xyz',
|
||||
});
|
||||
|
||||
expect(subject).toContain('Acme Holdings');
|
||||
expect(subject.toLowerCase()).toContain('fully signed');
|
||||
expect(html).toContain('all parties');
|
||||
expect(html).toContain('Sales Contract');
|
||||
expect(html).toContain('Acme Holdings');
|
||||
expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/xyz');
|
||||
expect(text).toContain('Acme Holdings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Boundaries the sender depends on — mock the I/O edges, exercise the real
|
||||
// wiring (recipient resolution → template → per-recipient send).
|
||||
vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/lib/email/branding-resolver', () => ({
|
||||
getBrandingShell: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock('@/lib/services/notification-recipients', () => ({
|
||||
resolveNotificationRecipients: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||
import { sendSigningStatusNotification } from '@/lib/services/document-signing-emails.service';
|
||||
|
||||
const mockSendEmail = vi.mocked(sendEmail);
|
||||
const mockResolve = vi.mocked(resolveNotificationRecipients);
|
||||
|
||||
const baseArgs = {
|
||||
portId: 'port-1',
|
||||
portName: 'Port Nimara',
|
||||
event: 'signed' as const,
|
||||
documentLabel: 'Expression of Interest',
|
||||
clientName: 'Jane Doe',
|
||||
crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
|
||||
signerName: 'Jane Doe',
|
||||
signerRole: 'client' as const,
|
||||
signedCount: 1,
|
||||
totalCount: 3,
|
||||
};
|
||||
|
||||
describe('sendSigningStatusNotification', () => {
|
||||
beforeEach(() => {
|
||||
mockSendEmail.mockClear();
|
||||
mockResolve.mockReset();
|
||||
});
|
||||
|
||||
it('emails every configured recipient when a signer signs', async () => {
|
||||
mockResolve.mockResolvedValue(['admin@portnimara.com', 'sales@portnimara.com']);
|
||||
|
||||
await sendSigningStatusNotification(baseArgs);
|
||||
|
||||
// Resolves from the signing list, falling back to the reply-to address.
|
||||
expect(mockResolve).toHaveBeenCalledWith(
|
||||
'port-1',
|
||||
'signing_notification_recipients',
|
||||
'email_reply_to',
|
||||
);
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(2);
|
||||
const recipients = mockSendEmail.mock.calls.map((c) => c[0]);
|
||||
expect(recipients).toContain('admin@portnimara.com');
|
||||
expect(recipients).toContain('sales@portnimara.com');
|
||||
// Subject reflects who signed.
|
||||
const subject = mockSendEmail.mock.calls[0]?.[1] as string;
|
||||
expect(subject).toContain('Jane Doe');
|
||||
// portId threaded through so per-port From + redirect apply.
|
||||
expect(mockSendEmail.mock.calls[0]?.[5]).toBe('port-1');
|
||||
});
|
||||
|
||||
it('sends nothing when no recipients are configured or resolvable', async () => {
|
||||
mockResolve.mockResolvedValue([]);
|
||||
|
||||
await sendSigningStatusNotification(baseArgs);
|
||||
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the completion subject for the completed event', async () => {
|
||||
mockResolve.mockResolvedValue(['sales@portnimara.com']);
|
||||
|
||||
await sendSigningStatusNotification({
|
||||
...baseArgs,
|
||||
event: 'completed',
|
||||
signerName: null,
|
||||
});
|
||||
|
||||
const subject = mockSendEmail.mock.calls[0]?.[1] as string;
|
||||
expect(subject.toLowerCase()).toContain('fully signed');
|
||||
expect(subject).toContain('Jane Doe');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user