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