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
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
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');
|
|
});
|
|
});
|