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:
2026-05-13 14:17:39 +02:00
parent 4d1fbcd469
commit adebd5f91d
8 changed files with 182 additions and 39 deletions

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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 },

View File

@@ -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