Six audit documents capture the 2026-05-06 review pass (comprehensive, frontend, missing-features, permissions, reliability) along with the Documenso integration audit + locked build plan that drove the bulk of subsequent feature work. Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous push — every item marked DONE or REMAINING with file pointers and scope estimates so future sessions can pick up where this one stopped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48 KiB
Documenso signing-flow build plan
Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.
Companion docs:
- docs/documenso-integration-audit.md — what's already built, v1/v2 endpoint mapping, nginx CORS block
- Old system reference: client-portal/server/api/eoi/generate-quick-eoi.ts, client-portal/server/api/webhooks/documenso.post.ts, client-portal/server/services/documenso-notifications.ts, Port Nimara/Website/pages/sign/[type]/[token].vue
Locked design decisions (from user, do NOT re-ask)
| Q | Decision |
|---|---|
| Embedded signing host | portnimara.com/sign/<role>/<token> (marketing website hosts the embed page; CRM emits URLs in this format) |
| Initial "please sign" email | Per-port admin setting eoi_send_mode: auto = send branded email immediately on generate; manual = generate + show URL + Send button |
| Contract / Reservation generation | Upload-and-place-fields per deal only. EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.) |
| Reminder cadence | Manual by default. Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1) |
| Document expiration | Never expire. No expiresAt UI in v1. (Resolved Q2) |
| Approver vs CC | Two concepts: APPROVER = real Documenso recipient that gates signing; Completion CC = passive recipient that only receives the signed PDF. (Resolved Q4) |
| Witness | First-class signer role. Configurable per-document; full reminder/tracking flow. (Resolved Q7) |
| Per-port developer label | Configurable via documenso_developer_label / documenso_approver_label. (Resolved Q8 bonus) |
| Multi-port template config | All Documenso settings are per-port via /[portSlug]/admin/documenso (already wired) |
| Documenso API version | Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope |
| nginx CORS | User applies manually. Block is in docs/documenso-integration-audit.md. Supports multi-origin via set $cors_origin regex |
| Signer override | Hybrid — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization. |
| Multi-berth | EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields |
| Test mode | Reuse EMAIL_REDIRECT_TO env var (already redirects every outbound email + Documenso recipient) |
| Regenerate handling | Match old system: 3 retries to delete prior Documenso doc with 2-second wait. Plus a confirm modal: "Retain old EOI? (default no)" |
| Field placement strategy | Auto-detect (anchor text scanner) + manual drag-drop UI as safety net. Auto-detect populates the initial state; rep can drag/delete/reassign before sending. |
What's already shipped (foundation)
Files in place; do NOT rebuild:
src/lib/services/port-config.ts— extended with:documenso_developer_name/email,documenso_approver_name/email,eoi_send_mode,embedded_signing_host,documenso_contract_template_id,documenso_reservation_template_idsrc/app/(dashboard)/[portSlug]/admin/documenso/page.tsx— admin UI exposes every Documenso knob across 5 cardssrc/lib/email/templates/document-signing.ts—signingInvitationEmail,signingCompletedEmail,signingReminderEmailwith per-port brandingsrc/lib/services/document-signing-emails.service.ts—sendSigningInvitation,sendSigningReminder,sendSigningCompleted. IncludestransformSigningUrl(rawUrl, host, role)for embed URL wrappingsrc/lib/services/documenso-client.ts— extendedDocumensoFieldTypeto all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typedDocumensoTextFieldMeta/NumberFieldMeta/ChoiceFieldMetainterfaces andfieldTypeNeedsMeta(type)helpersrc/components/interests/interest-eoi-tab.tsx— EOI workspace with active-doc hero, signing progress, paper-signed upload, history stripsrc/components/interests/interest-contract-tab.tsx— Contract workspace shell with paper-signed upload + "send for signing" placeholder dialogsrc/components/interests/interest-reservation-tab.tsx— Reservation workspace shell (clone of Contract)src/components/interests/interest-tabs.tsx— stage-conditional visibility wired
What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.
Phase 1 — EOI generate flow polish (~3 hours)
Updated for Q1, Q4, Q6, Q8 resolutions. Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and
transformSigningUrlrole mapping (Risk #5 fix).
Why first: Smallest surface area, validates the per-port eoi_send_mode setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.
Tasks
-
Auto-send wiring: in
src/components/documents/eoi-generate-dialog.tsx, afterhandleGenerate()succeeds:- Fetch port's
eoi_send_mode(already ongetPortDocumensoConfig(portId)) - If
auto: server-side already sent the doc to Documenso withsendEmail: false. Now call new endpointPOST /api/v1/documents/[id]/send-invitation(build it) which:- Looks up the document's signers
- Calls
sendSigningInvitation()for the first signer (the client; signing order 1) - Stores
sent_attimestamp on the signer row
- If
manual: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
- Fetch port's
-
Regenerate confirm modal: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (
activeDoc !== null):- Show a
<Dialog>asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled." - Two buttons: "Cancel" (default), "Regenerate" (destructive)
- Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
- On confirm: if checkbox unchecked, call
voidDocument(oldId, portId)with 3 retries + 2-second wait between (mirror old system'sgenerate-quick-eoi.tslines 110-162). Then run the normal generate flow.
- Show a
-
Send-invitation endpoint: new file
src/app/api/v1/documents/[id]/send-invitation/route.ts:POST /api/v1/documents/[id]/send-invitation Body: { recipientId?: string } // optional — defaults to first unsigned recipient- Loads the document + signers
- Resolves the target recipient (passed-in or first unsigned in signing order)
- Resolves port's documenso config + the recipient's signing URL from the document_signers row
- Calls
sendSigningInvitationfrom the email service - Updates
document_signers.invited_at(need to add column — see schema migration below)
-
Schema migration: add
invited_atandlast_reminder_sent_atcolumns todocument_signers:ALTER TABLE document_signers ADD COLUMN invited_at timestamptz; ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).
Acceptance criteria
- Setting
eoi_send_mode=autoin admin → generating an EOI fires off our branded HTML email to the client immediately - Setting
eoi_send_mode=manual→ no email fires; "Send invitation" button in EOI tab hits the endpoint - Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries
Phase 2 — Webhook handler enhancement (~3-4 hours)
Why second: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.
Tasks
-
Extend
src/app/api/webhooks/documenso/route.tsto handleDOCUMENT_OPENED,DOCUMENT_SIGNED,DOCUMENT_COMPLETED(DOCUMENT_OPENED currently ignored). -
For
DOCUMENT_SIGNED(fires when one recipient signs, can fire multiple times per doc):- Resolve the (port, document, signer) — existing per-port secret lookup already does this
- Update
document_signers.signed_atfor the matching signer - Find the next unsigned signer in signing order
- If next signer exists AND we haven't already invited them: call
sendSigningInvitation()with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Markdocument_signers.invited_atfor them. - This is the cascading "your turn" flow that mirrors
client-portal/server/services/documenso-notifications.ts
-
For
DOCUMENT_OPENED:- Update
document_signers.opened_atfor the matching recipient (matched by token in payload) - Used for analytics later ("12% of clients open within an hour")
- Update
-
For
DOCUMENT_COMPLETED(fires once when all signers have signed):- Update document
status='completed',completed_at=... - Download signed PDF:
await downloadSignedPdf(documensoId, portId)(existing) - Store in storage backend via the file ingestion flow — this creates a
filesrow - Update the document row to point at the signed file (
signed_file_id) - Call
sendSigningCompleted()with all signers + the signed file's id - Update the linked interest's pipeline stage:
- If document type =
eoi→eoi_signed - If document type =
contract→contract_signed - If document type =
reservation_agreement→ leave stage; reservation is post-deal-close anyway
- If document type =
- Update document
-
Recipient-token matching: webhooks include
payload.recipients[]with each recipient'stoken. Use the token to match againstdocument_signers.signing_token(need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust. -
Idempotency: webhook can fire duplicates. Old system's
acquireWebhookLock+ signature comparison pattern is good. Port that logic.
Schema migration
-- Add fine-grained tracking columns to document_signers
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text; -- index this
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
Acceptance criteria
- Client signs → developer receives our branded "your turn" email within seconds
- Developer signs → approver receives the same
- All signed → all three recipients receive the signed PDF as attachment
- Interest's pipeline stage advances to
eoi_signedautomatically - Re-firing of duplicate webhooks is no-op
Phase 3 — Custom document upload-to-Documenso (~6-8 hours)
Why third: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.
Tasks
-
New service
src/lib/services/custom-document-upload.service.ts:export async function uploadDocumentForSigning(args: { interestId: string; portId: string; documentType: 'contract' | 'reservation_agreement'; pdfBuffer: Buffer; filename: string; title: string; recipients: Array<{ name: string; email: string; role: 'SIGNER' | 'APPROVER' | 'CC'; signingOrder: number; }>; fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement }): Promise<{ documentId: string; signingUrls: Record<string, string> }>;Steps:
- Convert pdfBuffer → base64
- Call
createDocument(title, base64, recipients, portId)— existing client function - Call
placeFields(docId, fields, portId)— existing client function (handles v1 + v2) - Call
sendDocument(docId, portId)— existing - Return doc ID + per-recipient signing URLs
- Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
- Insert a row into our
documentstable with the new doc_id + signers + interest link - If port's
eoi_send_mode === 'auto': kick offsendSigningInvitation()to first signer
-
API endpoint:
POST /api/v1/interests/[id]/upload-for-signing- Accepts multipart:
file(the PDF),documentType,title,recipients(JSON),fields(JSON) - Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
- Calls service
- Returns 201 with the new document row
- Accepts multipart:
-
Update Contract + Reservation tab placeholders to open a real upload dialog (see Phase 4).
Acceptance criteria
- Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
- Document appears in the Documents tab with status
sent - v1 and v2 paths both work (same code path; client chooses based on per-port config)
Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)
Why fourth: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.
Sub-phase 4a: Recipient configurator (~2-3 hours)
UI inside a new <UploadForSigningDialog> component:
- File picker (drag-drop + click)
- Title input (defaults to filename minus extension)
- Recipients list:
- Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
- Drag to reorder (uses
dnd-kit, already in deps) - Delete row
- Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
- "Configure fields →" button advances to sub-phase 4b
Sub-phase 4b: PDF rendering (~3-4 hours)
- Install:
pnpm add react-pdf(uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight) - Render the uploaded PDF page-by-page using
<Document>+<Page>from react-pdf - Page navigation (prev/next, page picker)
- Zoom controls (50%, 75%, 100%, 125%, 150%)
Sub-phase 4c: Auto-detect scanner (~4-6 hours)
New file src/lib/services/document-field-detector.ts:
export interface DetectedField {
type: DocumensoFieldType;
pageNumber: number;
pageX: number; // 0-100 percent
pageY: number;
pageWidth: number;
pageHeight: number;
/** Confidence 0-1 — how sure the scanner is. */
confidence: number;
/** Original anchor text (for debugging / display). */
anchorText?: string;
/** Inferred recipient (from nearby labels). null = unassigned. */
inferredRecipientLabel?: string | null;
}
export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;
Implementation:
- Use
pdfjs-distto extract text per page withgetTextContent()— gives{str, transform: [a,b,c,d,e,f]}per text item wheree,fis position in PDF user space, pluswidth/height - Anchor patterns:
SIGNATURE:/signature[:\s_-]+/i,/sign\s*here[:\s_-]*/i,/X\s*_{4,}/i,/signed\s*by[:\s]+/iINITIALS:/initials?[:\s_-]+/iDATE:/dated?[:\s_-]+/i,/date\s+of\s+signature/iNAME:/(printed?\s*)?name[:\s_-]+/i,/full\s+name[:\s_-]+/iEMAIL:/email[:\s_-]+/i- Catch-all:
/_{8,}/→ if not preceded by name/email/date keyword, default to TEXT
- For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
- SIGNATURE: 150pt × 30pt
- INITIALS: 50pt × 30pt
- DATE: 80pt × 20pt
- NAME: 150pt × 20pt
- EMAIL: 200pt × 20pt
- TEXT: 200pt × 20pt
- Convert to PERCENT (divide by page width/height)
- Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.
Sub-phase 4d: Drag-drop overlay (~3-4 hours)
- Overlay absolute-positioned divs on top of the PDF viewer for each field
- Each field shows: type icon + recipient color + delete (×) handle + drag affordance
- Use
dnd-kitto enable drag — updatepageX/pageYin state on drop - Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
- Side panel for selected field:
- Type changer (dropdown)
- Recipient assignment (dropdown of configured recipients)
- Required toggle
- Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives
fieldMeta - Width/height inputs
- Delete button
Sub-phase 4e: Send (~1 hour)
"Send for signing" button:
- Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
- POSTs to
/api/v1/interests/[id]/upload-for-signing(Phase 3) - On success, closes dialog and refreshes the Contract/Reservation tab
Acceptance criteria
- Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
- Rep can drag any field to reposition (state updates, persists to backend on send)
- Rep can change a field's type, recipient, or metadata via side panel
- Rep can add new fields by clicking palette button + clicking on PDF
- Rep can delete fields they don't want
- Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc
Phase 5 — Embedded signing URL emission verification (~1-2 hours)
Why later: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.
Tasks
-
Verify URL transformation matches website expectations:
- Website route:
/sign/[type]/[token]wheretype ∈ {client, cc, developer} - Our
transformSigningUrl()emits/sign/<role>/<token>where role can beclient | developer | approver | witness | other - Mismatch: website only handles
client | cc | developer. Our email service may emitapprover(which the website doesn't route). - Fix: either (a) update website's
[type].vueto acceptapprover(andwitness | otherif needed), OR (b) map our role names to the website's expected names intransformSigningUrl().
- Website route:
-
For contract + reservation document types: the website's
signerMessagesmap only covers EOI-specific copy. When a contract goes out for signing and the recipient hitsportnimara.com/sign/client/<token>, the page would show "Sign Your Expression of Interest" — wrong copy.- Fix: add document-type to the URL too:
/sign/<docType>/<role>/<token>. Update website's signerMessages to be keyed on(docType, role).
- Fix: add document-type to the URL too:
-
Webhook callback URL: website POSTs to
client-portal.portnimara.com/api/webhook/document-signedafter signing. The new CRM is at a different domain. Update website'shandleDocumentSignedto POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook). -
Apply nginx CORS block — already documented in docs/documenso-integration-audit.md. Apply via ssh when user grants access.
Acceptance criteria
- Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
- Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)
Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)
autosend mode delay: optional per-porteoi_send_delay_minutessetting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).- Audit log entries: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to
audit_logswith structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions. - Per-document customization of email copy: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as
documents.invitation_message. - Document expiration: Documenso supports
expiresAt. Surface as a per-document field in the upload dialog. - Reminder rate-limit display: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
- Failed-webhook recovery UI: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.
Phase 7 — Project Director role + RBAC layer (~6-8 hours)
Surfaced from Q8 conversation. The
developersigner slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.
What a Project Director needs (vs sales rep)
| Capability | Sales rep | Project Director | Admin |
|---|---|---|---|
| Generate EOI / contract / reservation | ✓ | ✓ | ✓ |
| Approve / sign as the "developer" recipient on Documenso | — | ✓ | — (unless also designated PD) |
| View own deals | ✓ | ✓ | ✓ |
| View other reps' deals | — | ✓ | ✓ |
| View audit logs (read-only) | — | ✓ | ✓ |
| Trigger CSV / report exports | — | ✓ | ✓ |
| Re-assign deals between reps | — | ✓ | ✓ |
| Edit per-port settings | — | — | ✓ |
| Manage users + invitations | — | — | ✓ |
| Manage Documenso config | — | — | ✓ |
So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.
Tasks
-
Add
project_directorto the role enum insrc/lib/db/schema/users.ts(or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive. -
Permission flags: extend the per-port permissions matrix (
src/lib/auth/permissions.tsor equivalent) with new flags:viewAllDeals— true for project_director, admin, super_adminviewAuditLogs— true for project_director, admin, super_adminexportReports— true for project_director, admin, super_adminreassignDeals— true for project_director, admin, super_adminsignAsProjectDirector— true for project_director only (admin can sign as PD only if also assigned the role on this port)
These flags get checked in the relevant API handlers via the existing
withPermission()middleware. -
Documenso developer-slot binding: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:
- Populates
documenso_developer_name/emailfrom the user's profile (read-only when bound) - When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
- Free-text fallback stays for ports without a CRM-PD user yet
- Populates
-
User invitations + role selection: extend
src/components/admin/invite-user-dialog.tsxto surface "Project Director" alongside Sales / Admin as a selectable role at invitation time. -
Audit-log access: surface a new
/[portSlug]/admin/audit-logroute (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins. -
Reports page permission gate: existing
/[portSlug]/reports(or wherever exports live) checksexportReportspermission flag instead of admin-only. -
Re-assign deals UI: add a "Re-assign owner" action on the interest detail page, gated by
reassignDeals. Writes tointerests.owner_user_id(or whatever the assigned-rep field is) and audit-logs the change.
Schema migration
-- Add project_director as a valid role; depends on how roles are stored.
-- If port_roles uses an enum:
ALTER TYPE port_role ADD VALUE 'project_director';
-- Or if it's a text column with check constraint:
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));
-- Optional: link the per-port Documenso developer slot to a real user
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)
Acceptance criteria
- A user invited as
project_directorcan view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users - Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
- Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
- Existing sales / admin / super_admin permissions are unchanged
Why attack at the same time as the Documenso build
- Both touch
port-config.tsandadmin/documenso/page.tsx— fewer rebases if done in one push - The
documenso_developer_labelsetting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice - The Documenso webhook's per-signer matching benefits from having a real
users.emailto bind against, not just a free-text developer name
Out of scope (defer to a later RBAC pass)
- Custom permission templates (e.g. "PD with no audit-log access")
- Per-deal ACLs (sharing a single interest with another rep)
- Time-bound role grants
- Cross-port role overrides for super_admin
Risks + decisions (resolved through code review)
Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the Decision is what the next session should implement.
1. fieldMeta on Documenso v1.32
Q: Does v1.32 silently ignore unknown properties, or does it reject the request?
Decision: not a risk in current code. src/lib/services/documenso-client.ts:491-501 shows the v1 path constructs its own body containing only recipientId, type, pageNumber, pageX/Y/Width/Height — fieldMeta is never sent on v1. The code comment at line 341-344 is misleading — update it. Action for next session: change the comment to "v1 does not receive fieldMeta (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.
2. PDF dimension extraction (non-A4 contracts)
Q: How do we get real page dimensions on the v1 path?
Decision: parse the PDF with pdf-lib in the upload service before calling placeFields(). pdf-lib is already a transitive dep via the EOI form-fill flow (src/lib/pdf/fill-eoi-form.ts). Concrete change for Phase 3:
// In src/lib/services/custom-document-upload.service.ts
import { PDFDocument } from 'pdf-lib';
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pageDims = pdfDoc.getPages().map((p) => {
const { width, height } = p.getSize();
return { width, height };
});
// Pass to placeFields as a per-page dimension map override
Then extend placeFields signature to accept an optional pageDimensionsOverride?: DocumensoPageDimensions[] (one entry per page). When provided, the v1 path uses pageDimensionsOverride[fieldPageIndex] instead of getPageDimensions()'s A4 default. Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.
3. Multi-page signature blocks not picked up by auto-detect
Q: What's the recovery path if the scanner misses a signature block on the last page?
Decision: not a risk — by design. Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.
4. Webhook payload differences v1 vs v2
Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?
Decision: partially confirmed; finish the audit in Phase 2. Confirmed working today:
- Secret transport: identical (
X-Documenso-Secretplaintext) — see route.ts:53 - Event names: both versions send the uppercase Prisma enum (
DOCUMENT_SIGNED); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat. - Top-level shape
{ event, payload: { id, ... } }: same on both versions
Still unverified (defer to Phase 2 implementation):
- v2 may rename
payload.id→payload.documentIdandrecipient.id→recipient.recipientId(mirrors the API-response rename — see src/lib/services/documenso-client.tsnormalizeDocument()). Apply the same dual-field read pattern in the webhook handler:const docId = payload.documentId ?? payload.id. - v2 may include
payload.envelopeIdinstead ofpayload.idfor envelope-level events (DOCUMENT_COMPLETED). Read both. - Recipient token field: v1 uses
recipient.token; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.
Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.
5. approver role → cc URL mapping
Q: How do we keep the website's signing page (which only routes client | cc | developer) working when our SignerRole includes approver | witness | other?
Decision: confirmed bug in current code; fix in Phase 5. Website route validation explicitly redirects to /sign/error for any signerType not in ['client', 'cc', 'developer']. Our transformSigningUrl() emits ${host}/sign/${signerRole}/${token} with the raw SignerRole value. Today, an approver invite would land on /sign/error.
Concrete fix in transformSigningUrl():
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
client: 'client',
developer: 'developer',
approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
other: 'cc',
};
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
return `${host}/sign/${urlRole}/${token}`;
Two follow-ups for Phase 5:
- Add the mapping above to
transformSigningUrl()— DO this in Phase 1 already since Phase 1 fires the first invitation email. - Update website's
signerMessages(currently EOI-specific) to be keyed on(documentType, signerType)so contract+reservation invites get the right copy — see Phase 5 task 2.
6. Storage backend for signed PDFs
Q: Does the on-completion download in Phase 2 use the pluggable storage backend?
Decision: confirmed — pattern already established, just follow it. getStorageBackend() is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The documents schema already has the signedFileId column with index idx_docs_signed_file_id. Phase 2 step 4 is just: const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id }).... Demoted from "risk" to "implementation note" inside Phase 2.
7. Cross-port webhook secret collision
Q: Can two ports happen to share the same webhook secret?
Decision: real risk — fix at write-time, not schema. system_settings is unique on (key, port_id), so the same key+port combo is enforced unique, but there's no global uniqueness on the value. The webhook handler iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:
Option A (recommended): generate, never paste. Replace the textbox in admin/documenso/page.tsx for documenso_webhook_secret with a "Generate secret" button that calls crypto.randomBytes(32).toString('base64url') server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.
Option B: warn at write. Keep the textbox but on PUT to the setting, query system_settings WHERE key='documenso_webhook_secret' AND value=? and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.
Option C: schema-level enforcement. Add a partial unique index CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.
Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.
Open questions — RESOLVED 2026-05-07
All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.
Q1. Reminder cadence — RESOLVED
Decision: Manual reminders by default. Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").
Implications:
- No port-wide reminder schedule setting needed.
- Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a
POST /api/v1/documents/[id]/send-reminderendpoint that callssendSigningReminder()for the next-pending signer. Tracklast_reminder_sent_atto enforce Documenso's 24h rate limit on the UI ("Next reminder available in X"). - Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on
documents.auto_reminder_interval_days; a once-daily worker iterates unsigned documents and fires due reminders.
Q2. Document expiration — RESOLVED
Decision: Never expire by default. No expiration UI in v1. Skip Documenso's expiresAt entirely.
Reasoning: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.
Implications:
- Phase 3
uploadDocumentForSigning: don't exposeexpiresAt. - Phase 4a recipient configurator: no expiration field.
- Phase 6 deferred-items list: drop the "Document expiration" item.
Q3. Auto-detect confidence threshold — RESOLVED
Decision: Default ≥0.8 silent / 0.5–0.8 flagged / <0.5 drop, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.
Implications:
- Phase 4c scanner: emit
DetectedField.confidence; threshold checks live in the UI layer (Phase 4d) so they're easy to tune. - Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.
Q4. Approver semantics — RESOLVED
Decision: TWO concepts, not one.
- APPROVER = real Documenso
APPROVERrecipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existingdocumenso_approver_name/emailsettings). - Completion CC = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.
Implications:
- Phase 3
uploadDocumentForSigningrecipients: supportrole: 'SIGNER' | 'APPROVER' | 'CC'. CCs are NOT created as Documenso recipients — they're stored ondocuments.completion_cc_emails(text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires. - Phase 4a recipient configurator: split into two sections:
- Signing recipients: name + email + role (Signer / Approver) + signing order
- Copy on completion (CC): just email addresses, comma-separated
- Phase 2 step 4 (on-completion email distribution): include
documents.completion_cc_emailsrecipients with the signed PDF. Dedup by email (see Q5). - Schema migration:
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
Q5. On-completion PDF distribution — RESOLVED
Decision: All signing recipients + rep who generated + per-deal CC, deduplicated by email address.
Implications:
- Phase 2 step 4: build the recipient list as union of (a) all
document_signersfor this doc, (b) the user who created the doc (documents.createdBy→users.email), (c)documents.completion_cc_emails. Lowercase + dedupe before callingsendSigningCompleted. - Common case (rep IS the approver): one email, not two.
- Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants
legal@portnimara.comon every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).
Q6. documenso_contract_template_id / documenso_reservation_template_id — RESOLVED
Decision: DROP both settings. EOI is the only template-driven flow. Contracts and reservations are custom-uploaded per deal — no template fallback.
Implications:
- Remove
documenso_contract_template_idanddocumenso_reservation_template_idfromport-config.tsSETTING_KEYSandPortDocumensoConfigtype. - Remove the corresponding fields from
admin/documenso/page.tsx. Card title becomes "Templates" with just the EOI template ID field. - Phase 3: contract/reservation tabs go straight into the upload dialog — no
if (templateId) { ... }branch. - Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.
Q7. Witness role — RESOLVED
Decision: First-class. Configurable per-document at generation time. Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.
Implications:
- Keep
witnessinSignerRole. - Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
- Phase 5 website edit: add witness copy to
signerMessagesmap ("Witness this signing of…"). Addwitnessto the validated role list at line 175 of[type]/[token].vue— currently['client', 'cc', 'developer'], becomes['client', 'cc', 'developer', 'witness']. - Risk #5 mapping in
transformSigningUrl():witness → 'witness'(NOT mapped tocc). Update the role-to-URL-segment table accordingly. - Witness gets the same reminder/auto-reminder support as any signer — no special-casing.
Q8. Multiple developers/approvers per port — RESOLVED (with rename)
Decision: Stay single per port for the standard developer and approver slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).
Plus the bonus: the per-port "developer" label IS configurable via a new documenso_developer_label setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays developer so no schema churn.
Implications:
- Add
documenso_developer_labelanddocumenso_approver_labeltoSETTING_KEYS+PortDocumensoConfig. - Admin UI in
documenso/page.tsxSigners card: each signer card gets a "Display label" input next to name/email. - Email templates in
document-signing.ts: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…"). - Open follow-up (out of scope for Documenso build): the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.
Q9. Field placement draft persistence — RESOLVED
Decision: No persistence. If the rep closes the dialog mid-placement, state is lost.
Implications:
- Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
- Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").
Q10. Embedded signing host fallback — RESOLVED
Decision: Send raw Documenso URLs when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. https://signatures.portnimara.dev/sign/<token>); transformSigningUrl() returns this raw URL untouched when embeddedSigningHost is null/empty (current behaviour, see document-signing-emails.service.ts:106-117).
Implications:
- Phase 1: no behaviour change in
transformSigningUrl(). The current null-host short-circuit IS the fallback. - Add a banner in the EOI/Contract tab when port has unset
embedded_signing_hostand at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages." - No new env var. No blocking-on-send.
Schema migration summary (resolved)
Combining all resolved decisions, the migrations needed are:
-- Phase 1 (also covers Phase 2's lifecycle tracking)
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text;
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
-- Phase 1 / Q4 (completion CCs are per-document)
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
-- Phase 1 / Q1 (auto-reminder opt-in per document)
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;
Settings to add / remove (resolved)
Add to SETTING_KEYS + PortDocumensoConfig:
documenso_developer_label(text, default "Developer") — Q8 bonusdocumenso_approver_label(text, default "Approver") — Q8 bonus
Remove from SETTING_KEYS + PortDocumensoConfig:
documenso_contract_template_id— Q6documenso_reservation_template_id— Q6
Remove from admin UI (admin/documenso/page.tsx):
- Contract template ID input — Q6
- Reservation template ID input — Q6
Add to admin UI:
- Display-label inputs next to developer + approver name/email pairs — Q8 bonus
Status: Plan is now fully resolved. Phase 1 can start without further clarification.
Quick file reference
Existing — modify in place:
src/lib/services/documenso-client.ts(extend createDocument for v2; add recipient management functions)src/lib/services/port-config.ts(no changes expected)src/lib/email/index.ts(consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)src/app/api/webhooks/documenso/route.ts(Phase 2 — major rewrite)src/components/interests/interest-contract-tab.tsx(replace ComingSoonDialog with UploadForSigningDialog in Phase 4)src/components/interests/interest-reservation-tab.tsx(same)src/components/documents/eoi-generate-dialog.tsx(Phase 1 — add regenerate confirm)
New files to create:
src/lib/services/custom-document-upload.service.ts(Phase 3)src/lib/services/document-field-detector.ts(Phase 4c)src/components/documents/upload-for-signing-dialog.tsx(Phase 4)src/components/documents/pdf-field-canvas.tsx(Phase 4b/4d)src/components/documents/recipient-configurator.tsx(Phase 4a)src/components/documents/field-palette-toolbar.tsx(Phase 4d)src/components/documents/field-config-side-panel.tsx(Phase 4d)src/app/api/v1/documents/[id]/send-invitation/route.ts(Phase 1)src/app/api/v1/interests/[id]/upload-for-signing/route.ts(Phase 3)- DB migrations for
document_signers.invited_atetc. (Phase 1, Phase 2)