feat(documenso-phase-6): activity badges + per-document invitation message
Two of the six Phase 6 polish items shipped in one commit because they
share the data + plumbing path (per-doc message uses the signing-
progress UI's existing layout).
1) Signing-progress activity badges
- Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all
populated by Phase 1+2 webhook handlers) per signer in the
existing progress widget. Each badge renders as
"Invited 2 hours ago / Opened yesterday / Reminded 3 days ago"
via Intl.RelativeTimeFormat.
- Resend button: was silent on success/failure; now uses
useMutation + toast so the rep sees whether the reminder fired
or fell into a cadence cooldown. Honours the existing
sendReminderIfAllowed return shape (`{sent, reason}`).
- Title-tooltips on each badge show the exact ISO timestamp.
2) Per-document custom invitation message
- New `documents.invitation_message` column (migration 0060;
applied via psql per the dev-flow note in CLAUDE.md).
- Textarea in UploadForSigningDialog step 2 (recipient configurator),
1000-char cap, placeholder text shows the expected tone.
- custom-document-upload.service accepts `invitationMessage`,
trims + stores on the documents row.
- sendCascadingInviteForNextSigner now reads
doc.invitationMessage and passes as customMessage so every
cascaded recipient (developer / approver / witness) sees the
same note — not just the first signer.
- send-invitation route (manual resend path) reads the same
column → customMessage so manual reminders match.
- The email template's existing customMessage rendering does
the XSS escape; no other plumbing needed.
Phase 6 items still deferred (each ~2-3h, mostly independent):
- Auto-send delay (`eoi_send_delay_minutes` setting + scheduled
BullMQ job — needs a scheduler hook).
- Document expiration (`documents.expires_at` + Documenso
`expiresAt` passthrough — needs Documenso v2 endpoint shape
verification).
- Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs
an admin page with Replay button).
Tests: 1340 → 1350 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,10 @@ export const POST = withAuth(
|
|||||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||||
signerRole: (target.signerRole as SignerRole) ?? 'client',
|
signerRole: (target.signerRole as SignerRole) ?? 'client',
|
||||||
senderName: docCfg.developerName ?? null,
|
senderName: docCfg.developerName ?? null,
|
||||||
|
// Phase 6 — surface the per-doc rep-authored note when set so
|
||||||
|
// every cascaded invite and any manual resend show the same
|
||||||
|
// copy. Falls back to the template default when null/empty.
|
||||||
|
customMessage: doc.invitationMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ export const POST = withAuth(
|
|||||||
// ─── scalar fields ─────────────────────────────────────────
|
// ─── scalar fields ─────────────────────────────────────────
|
||||||
const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType;
|
const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType;
|
||||||
const title = z.string().min(1).max(255).parse(form.get('title'));
|
const title = z.string().min(1).max(255).parse(form.get('title'));
|
||||||
|
const invitationMessageRaw = form.get('invitationMessage');
|
||||||
|
const invitationMessage =
|
||||||
|
typeof invitationMessageRaw === 'string'
|
||||||
|
? z.string().max(1000).parse(invitationMessageRaw)
|
||||||
|
: null;
|
||||||
|
|
||||||
// ─── JSON fields ───────────────────────────────────────────
|
// ─── JSON fields ───────────────────────────────────────────
|
||||||
const recipients = parseJsonField(
|
const recipients = parseJsonField(
|
||||||
@@ -142,6 +147,7 @@ export const POST = withAuth(
|
|||||||
signingOrder: r.signingOrder,
|
signingOrder: r.signingOrder,
|
||||||
})),
|
})),
|
||||||
fields,
|
fields,
|
||||||
|
invitationMessage,
|
||||||
meta: {
|
meta: {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
interface Signer {
|
interface Signer {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +14,10 @@ interface Signer {
|
|||||||
signingOrder: number;
|
signingOrder: number;
|
||||||
status: string;
|
status: string;
|
||||||
signedAt?: string | null;
|
signedAt?: string | null;
|
||||||
|
/** Phase 1+2 lifecycle columns surfaced on the API row. */
|
||||||
|
invitedAt?: string | null;
|
||||||
|
openedAt?: string | null;
|
||||||
|
lastReminderSentAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SigningProgressProps {
|
interface SigningProgressProps {
|
||||||
@@ -36,56 +43,124 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
approver: 'Sales/Approver',
|
approver: 'Sales/Approver',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 6 polish: human-readable "X minutes/hours/days ago" for the
|
||||||
|
* activity badges (invited / opened / last reminded). Uses
|
||||||
|
* Intl.RelativeTimeFormat so it follows the user's locale.
|
||||||
|
*/
|
||||||
|
function humanRelative(isoOrNull: string | null | undefined): string | null {
|
||||||
|
if (!isoOrNull) return null;
|
||||||
|
const t = new Date(isoOrNull).getTime();
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
const diffMs = Date.now() - t;
|
||||||
|
const seconds = Math.round(diffMs / 1000);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||||
|
if (Math.abs(seconds) < 60) return rtf.format(-Math.round(seconds), 'second');
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (Math.abs(minutes) < 60) return rtf.format(-minutes, 'minute');
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (Math.abs(hours) < 24) return rtf.format(-hours, 'hour');
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return rtf.format(-days, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
|
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
|
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
|
||||||
|
|
||||||
const handleResend = async (_signer: Signer) => {
|
// Phase 6 — surface reminder cooldown / success / error in a toast
|
||||||
try {
|
// rather than the silent catch the old handler used. Reps need to
|
||||||
await apiFetch(`/api/v1/documents/${documentId}/remind`, { method: 'POST' });
|
// know whether the manual "Resend" actually fired.
|
||||||
|
const remindMutation = useMutation({
|
||||||
|
mutationFn: (signerId: string) =>
|
||||||
|
apiFetch<{ data: { sent: boolean; reason?: string } }>(
|
||||||
|
`/api/v1/documents/${documentId}/remind`,
|
||||||
|
{ method: 'POST', body: { signerId } },
|
||||||
|
),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.data.sent) {
|
||||||
|
toast.success('Reminder sent.');
|
||||||
|
} else {
|
||||||
|
toast.info(res.data.reason ?? 'Reminder skipped.');
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
|
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
|
||||||
} catch {
|
},
|
||||||
// silent
|
onError: (err) => toastError(err, 'Failed to send reminder'),
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{sorted.map((signer, idx) => (
|
{sorted.map((signer, idx) => {
|
||||||
<div key={signer.id} className="flex items-center gap-2">
|
const invitedAgo = humanRelative(signer.invitedAt);
|
||||||
<div className="flex flex-col items-center gap-1">
|
const openedAgo = humanRelative(signer.openedAt);
|
||||||
<div
|
const remindedAgo = humanRelative(signer.lastReminderSentAt);
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
|
return (
|
||||||
>
|
<div key={signer.id} className="flex items-center gap-2">
|
||||||
{signer.signingOrder}
|
<div className="flex flex-col items-center gap-1">
|
||||||
</div>
|
<div
|
||||||
<div className="max-w-24 text-center">
|
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
|
||||||
<p className="truncate text-xs font-medium">{signer.signerName}</p>
|
>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
{signer.signingOrder}
|
||||||
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
|
</div>
|
||||||
</p>
|
<div className="max-w-28 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="truncate text-xs font-medium">{signer.signerName}</p>
|
||||||
{STATUS_LABELS[signer.status] ?? signer.status}
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
</p>
|
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
|
||||||
{signer.signedAt && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="text-xs text-muted-foreground">
|
||||||
{signer.status === 'pending' && (
|
{STATUS_LABELS[signer.status] ?? signer.status}
|
||||||
<button
|
</p>
|
||||||
onClick={() => handleResend(signer)}
|
{signer.signedAt && (
|
||||||
className="mt-1 text-xs text-primary underline hover:no-underline"
|
<p className="text-xs text-muted-foreground">
|
||||||
>
|
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
|
||||||
Resend
|
</p>
|
||||||
</button>
|
)}
|
||||||
)}
|
{/* Phase 6 polish — activity badges so reps can see at a
|
||||||
|
glance when each signer was last touched. */}
|
||||||
|
{signer.status === 'pending' && (invitedAgo || openedAgo || remindedAgo) && (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{invitedAgo && (
|
||||||
|
<p
|
||||||
|
className="text-[10px] text-muted-foreground"
|
||||||
|
title={signer.invitedAt ?? ''}
|
||||||
|
>
|
||||||
|
Invited {invitedAgo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{openedAgo && (
|
||||||
|
<p
|
||||||
|
className="text-[10px] text-muted-foreground"
|
||||||
|
title={signer.openedAt ?? ''}
|
||||||
|
>
|
||||||
|
Opened {openedAgo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{remindedAgo && (
|
||||||
|
<p
|
||||||
|
className="text-[10px] text-muted-foreground"
|
||||||
|
title={signer.lastReminderSentAt ?? ''}
|
||||||
|
>
|
||||||
|
Reminded {remindedAgo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{signer.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => remindMutation.mutate(signer.id)}
|
||||||
|
disabled={remindMutation.isPending}
|
||||||
|
className="mt-1 text-xs text-primary underline hover:no-underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{remindMutation.isPending ? 'Sending…' : 'Resend'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{idx < sorted.length - 1 && <div className="mb-6 h-0.5 w-8 shrink-0 bg-border" />}
|
||||||
</div>
|
</div>
|
||||||
{idx < sorted.length - 1 && <div className="mb-6 h-0.5 w-8 shrink-0 bg-border" />}
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ function DialogBody({
|
|||||||
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
||||||
const [fields, setFields] = useState<PlacedField[]>([]);
|
const [fields, setFields] = useState<PlacedField[]>([]);
|
||||||
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
||||||
|
// Phase 6 polish — optional rep-authored note that appears above the
|
||||||
|
// CTA in every invitation email for this doc. Empty string means
|
||||||
|
// "no custom note — use the template default copy".
|
||||||
|
const [invitationMessage, setInvitationMessage] = useState('');
|
||||||
|
|
||||||
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
||||||
|
|
||||||
@@ -314,6 +318,9 @@ function DialogBody({
|
|||||||
form.append('documentType', documentType);
|
form.append('documentType', documentType);
|
||||||
form.append('title', title || file.name.replace(/\.pdf$/i, ''));
|
form.append('title', title || file.name.replace(/\.pdf$/i, ''));
|
||||||
form.append('recipients', JSON.stringify(recipients));
|
form.append('recipients', JSON.stringify(recipients));
|
||||||
|
if (invitationMessage.trim()) {
|
||||||
|
form.append('invitationMessage', invitationMessage.trim());
|
||||||
|
}
|
||||||
// Strip the client-side `id` from each placed field — the server
|
// Strip the client-side `id` from each placed field — the server
|
||||||
// assigns its own ids on the documenso side.
|
// assigns its own ids on the documenso side.
|
||||||
form.append(
|
form.append(
|
||||||
@@ -403,6 +410,8 @@ function DialogBody({
|
|||||||
onChange={setRecipients}
|
onChange={setRecipients}
|
||||||
title={title}
|
title={title}
|
||||||
onTitleChange={setTitle}
|
onTitleChange={setTitle}
|
||||||
|
invitationMessage={invitationMessage}
|
||||||
|
onInvitationMessageChange={setInvitationMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'place-fields' && fileObjectUrl && (
|
{step === 'place-fields' && fileObjectUrl && (
|
||||||
@@ -584,11 +593,15 @@ function RecipientsStep({
|
|||||||
onChange,
|
onChange,
|
||||||
title,
|
title,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
|
invitationMessage,
|
||||||
|
onInvitationMessageChange,
|
||||||
}: {
|
}: {
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
onChange: (next: Recipient[]) => void;
|
onChange: (next: Recipient[]) => void;
|
||||||
title: string;
|
title: string;
|
||||||
onTitleChange: (t: string) => void;
|
onTitleChange: (t: string) => void;
|
||||||
|
invitationMessage: string;
|
||||||
|
onInvitationMessageChange: (next: string) => void;
|
||||||
}) {
|
}) {
|
||||||
function update(i: number, patch: Partial<Recipient>) {
|
function update(i: number, patch: Partial<Recipient>) {
|
||||||
const next = [...recipients];
|
const next = [...recipients];
|
||||||
@@ -668,6 +681,24 @@ function RecipientsStep({
|
|||||||
<Plus className="size-4" aria-hidden /> Add recipient
|
<Plus className="size-4" aria-hidden /> Add recipient
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invitation-message">
|
||||||
|
Optional message to include in the signing invitation
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="invitation-message"
|
||||||
|
value={invitationMessage}
|
||||||
|
onChange={(e) => onInvitationMessageChange(e.target.value)}
|
||||||
|
placeholder="Hi John — please review the attached contract before signing. Reach out if anything needs adjusting."
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Appears above the Sign button in every recipient’s invitation email. Plain text
|
||||||
|
only; 1000 characters max.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Phase 6 polish: per-document custom invitation message
|
||||||
|
--
|
||||||
|
-- Rep can author a short note in the upload-for-signing dialog that
|
||||||
|
-- gets inserted above the CTA in every signing-invitation email for
|
||||||
|
-- this document. Plain text (XSS-escaped by the email renderer).
|
||||||
|
-- Null means "no custom message — use the template default".
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN IF NOT EXISTS invitation_message text;
|
||||||
@@ -94,6 +94,11 @@ export const documents = pgTable(
|
|||||||
* N days until they complete. Null = manual reminders only. */
|
* N days until they complete. Null = manual reminders only. */
|
||||||
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
|
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
|
/** Phase 6 polish — rep-authored note inserted above the CTA in
|
||||||
|
* every signing-invitation email for THIS document. Falls back to
|
||||||
|
* the empty string when null. Plain-text (XSS-escaped by the
|
||||||
|
* email renderer); not Markdown. */
|
||||||
|
invitationMessage: text('invitation_message'),
|
||||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||||
createdBy: text('created_by').notNull(),
|
createdBy: text('created_by').notNull(),
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export interface UploadDocumentForSigningArgs {
|
|||||||
* it to the resolved Documenso recipient id after createDocument
|
* it to the resolved Documenso recipient id after createDocument
|
||||||
* responds. */
|
* responds. */
|
||||||
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
|
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
|
||||||
|
/** Phase 6 polish — optional rep-authored note inserted above the
|
||||||
|
* CTA in every signing-invitation email for this document. Stored
|
||||||
|
* on documents.invitation_message; falls back to the template
|
||||||
|
* default when null/empty. */
|
||||||
|
invitationMessage?: string | null;
|
||||||
meta: AuditMeta;
|
meta: AuditMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +126,7 @@ export async function uploadDocumentForSigning(
|
|||||||
filename,
|
filename,
|
||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
|
invitationMessage,
|
||||||
meta,
|
meta,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
@@ -239,6 +245,7 @@ export async function uploadDocumentForSigning(
|
|||||||
documentType,
|
documentType,
|
||||||
title,
|
title,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
|
invitationMessage: invitationMessage?.trim() || null,
|
||||||
createdBy: meta.userId,
|
createdBy: meta.userId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -390,6 +397,7 @@ export async function uploadDocumentForSigning(
|
|||||||
documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
|
documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
|
||||||
signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
|
signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
|
||||||
senderName: docCfg.developerName ?? null,
|
senderName: docCfg.developerName ?? null,
|
||||||
|
customMessage: invitationMessage?.trim() || null,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err, documentId: docRow.id, signerId: refreshed.id },
|
{ err, documentId: docRow.id, signerId: refreshed.id },
|
||||||
|
|||||||
@@ -1105,12 +1105,17 @@ export async function handleRecipientSigned(eventData: {
|
|||||||
* can be exercised by a dedicated unit test. Finds the next pending
|
* can be exercised by a dedicated unit test. Finds the next pending
|
||||||
* signer (lowest signing order), sends them a branded invitation, and
|
* signer (lowest signing order), sends them a branded invitation, and
|
||||||
* stamps `invitedAt` so a duplicate webhook delivery doesn't re-send.
|
* stamps `invitedAt` so a duplicate webhook delivery doesn't re-send.
|
||||||
|
*
|
||||||
|
* Phase 6: when the document carries a rep-authored
|
||||||
|
* `invitation_message`, it flows through as `customMessage` so every
|
||||||
|
* cascaded recipient (not just the first one) sees the same note.
|
||||||
*/
|
*/
|
||||||
async function sendCascadingInviteForNextSigner(doc: {
|
async function sendCascadingInviteForNextSigner(doc: {
|
||||||
id: string;
|
id: string;
|
||||||
portId: string;
|
portId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
invitationMessage: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const signers = await db
|
const signers = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -1145,6 +1150,7 @@ async function sendCascadingInviteForNextSigner(doc: {
|
|||||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||||
signerRole: (next.signerRole as SignerRole) ?? 'other',
|
signerRole: (next.signerRole as SignerRole) ?? 'other',
|
||||||
senderName: docCfg.developerName ?? null,
|
senderName: docCfg.developerName ?? null,
|
||||||
|
customMessage: doc.invitationMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
Reference in New Issue
Block a user