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

@@ -195,6 +195,10 @@ function DialogBody({
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [fields, setFields] = useState<PlacedField[]>([]);
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';
@@ -314,6 +318,9 @@ function DialogBody({
form.append('documentType', documentType);
form.append('title', title || file.name.replace(/\.pdf$/i, ''));
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
// assigns its own ids on the documenso side.
form.append(
@@ -403,6 +410,8 @@ function DialogBody({
onChange={setRecipients}
title={title}
onTitleChange={setTitle}
invitationMessage={invitationMessage}
onInvitationMessageChange={setInvitationMessage}
/>
)}
{step === 'place-fields' && fileObjectUrl && (
@@ -584,11 +593,15 @@ function RecipientsStep({
onChange,
title,
onTitleChange,
invitationMessage,
onInvitationMessageChange,
}: {
recipients: Recipient[];
onChange: (next: Recipient[]) => void;
title: string;
onTitleChange: (t: string) => void;
invitationMessage: string;
onInvitationMessageChange: (next: string) => void;
}) {
function update(i: number, patch: Partial<Recipient>) {
const next = [...recipients];
@@ -668,6 +681,24 @@ function RecipientsStep({
<Plus className="size-4" aria-hidden /> Add recipient
</Button>
</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&rsquo;s invitation email. Plain text
only; 1000 characters max.
</p>
</div>
</div>
);
}