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:
@@ -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. */
|
||||
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
|
||||
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),
|
||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||
createdBy: text('created_by').notNull(),
|
||||
|
||||
@@ -93,6 +93,11 @@ export interface UploadDocumentForSigningArgs {
|
||||
* it to the resolved Documenso recipient id after createDocument
|
||||
* responds. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -121,6 +126,7 @@ export async function uploadDocumentForSigning(
|
||||
filename,
|
||||
recipients,
|
||||
fields,
|
||||
invitationMessage,
|
||||
meta,
|
||||
} = args;
|
||||
|
||||
@@ -239,6 +245,7 @@ export async function uploadDocumentForSigning(
|
||||
documentType,
|
||||
title,
|
||||
status: 'draft',
|
||||
invitationMessage: invitationMessage?.trim() || null,
|
||||
createdBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
@@ -390,6 +397,7 @@ export async function uploadDocumentForSigning(
|
||||
documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
|
||||
signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
|
||||
senderName: docCfg.developerName ?? null,
|
||||
customMessage: invitationMessage?.trim() || null,
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
{ 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
|
||||
* signer (lowest signing order), sends them a branded invitation, and
|
||||
* 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: {
|
||||
id: string;
|
||||
portId: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
invitationMessage: string | null;
|
||||
}): Promise<void> {
|
||||
const signers = await db
|
||||
.select()
|
||||
@@ -1145,6 +1150,7 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
signerRole: (next.signerRole as SignerRole) ?? 'other',
|
||||
senderName: docCfg.developerName ?? null,
|
||||
customMessage: doc.invitationMessage,
|
||||
});
|
||||
|
||||
await db
|
||||
|
||||
Reference in New Issue
Block a user