diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index e6793eee..55ceab96 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -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', diff --git a/src/lib/email/templates/signing-status-notification.tsx b/src/lib/email/templates/signing-status-notification.tsx new file mode 100644 index 00000000..ca62403e --- /dev/null +++ b/src/lib/email/templates/signing-status-notification.tsx @@ -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 ( + <> + + {heading} + + {isCompleted ? ( + + The {data.documentLabel} for {data.clientName} has now been signed by all + parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM. + + ) : ( + + {data.signerName ?? 'A signer'} ({roleLabel(data.signerRole)}) has signed + the {data.documentLabel} for {data.clientName} + {progress ? ` — ${progress} signatures collected so far.` : '.'} + + )} +
+ +
+
+ + Open the deal:{' '} + + {data.crmUrl} + + + + 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. + + + ); +} + +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(, { 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, + }; +} diff --git a/src/lib/services/document-signing-emails.service.ts b/src/lib/services/document-signing-emails.service.ts index a69e2017..4d69c16e 100644 --- a/src/lib/services/document-signing-emails.service.ts +++ b/src/lib/services/document-signing-emails.service.ts @@ -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 { + 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', + ); + } + }), + ), + ); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 5e5598eb..04bea46b 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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[1] & { + id: string; + portId: string; + documentType: string; + title: string; + }, + event: 'signed' | 'completed', + signer?: { name: string; role: string | null } | null, +): Promise { + 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 }) { diff --git a/tests/unit/email/signing-status-notification.test.ts b/tests/unit/email/signing-status-notification.test.ts new file mode 100644 index 00000000..b70e67ae --- /dev/null +++ b/tests/unit/email/signing-status-notification.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/services/signing-status-notification.service.test.ts b/tests/unit/services/signing-status-notification.service.test.ts new file mode 100644 index 00000000..d133ef32 --- /dev/null +++ b/tests/unit/services/signing-status-notification.service.test.ts @@ -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'); + }); +});