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

@@ -77,6 +77,10 @@ export const POST = withAuth(
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
signerRole: (target.signerRole as SignerRole) ?? 'client',
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

View File

@@ -114,6 +114,11 @@ export const POST = withAuth(
// ─── scalar fields ─────────────────────────────────────────
const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType;
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 ───────────────────────────────────────────
const recipients = parseJsonField(
@@ -142,6 +147,7 @@ export const POST = withAuth(
signingOrder: r.signingOrder,
})),
fields,
invitationMessage,
meta: {
userId: ctx.userId,
portId: ctx.portId,