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:
@@ -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’s invitation email. Plain text
|
||||
only; 1000 characters max.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user