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:
@@ -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',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user