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',
|
type: 'recipients',
|
||||||
defaultValue: [],
|
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',
|
key: 'eoi_signers',
|
||||||
label: '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,
|
signingInvitationEmail,
|
||||||
signingReminderEmail,
|
signingReminderEmail,
|
||||||
} from '@/lib/email/templates/document-signing';
|
} from '@/lib/email/templates/document-signing';
|
||||||
|
import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
|
||||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||||
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
||||||
|
import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── 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 {
|
import {
|
||||||
sendSigningInvitation,
|
sendSigningInvitation,
|
||||||
sendSigningCompleted,
|
sendSigningCompleted,
|
||||||
|
sendSigningStatusNotification,
|
||||||
type SignerRole,
|
type SignerRole,
|
||||||
} from '@/lib/services/document-signing-emails.service';
|
} from '@/lib/services/document-signing-emails.service';
|
||||||
import {
|
import {
|
||||||
@@ -1248,6 +1249,14 @@ export async function handleRecipientSigned(eventData: {
|
|||||||
'cascading "your turn" invite failed after recipient signed',
|
'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
|
* Manually (re)send the finalized signed PDF of a completed document to the
|
||||||
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
|
* 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 }) {
|
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