feat(signing): internal "who signed" + completion email alerts to configurable recipients
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m56s

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:
2026-06-24 21:38:22 +02:00
parent 352b2420b7
commit 1c91d76c52
6 changed files with 447 additions and 0 deletions

View File

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

View 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&apos;re receiving this because you&apos;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,
};
}

View File

@@ -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',
);
}
}),
),
);
}

View File

@@ -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 }) {

View 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');
});
});

View File

@@ -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');
});
});