Files
pn-new-crm/docs/documenso-integration-audit.md
Matt 4d1fbcd469 feat(documenso-phase-5): pin transformSigningUrl + document website-side coordination
Phase 5 is mostly coordination + verification rather than a code
build — the embedded signing pages live in a different repo. What
lands here:

1. transformSigningUrl hardening — routes through extractSigningToken
   so a bare URL like `https://sig.example.com` no longer produces
   the malformed `<host>/sign/<role>/sig.example.com`. The token
   validator (≥8 URL-safe chars) rejects malformed tails so the
   function falls back to returning the raw URL.

2. 10 unit tests pin the role-segment mapping so a future refactor
   can't silently break the contract with the marketing website's
   /sign/[type]/[token] page. Covers:
     - all five SignerRole → URL segment mappings
     - trailing-slash normalization on the host
     - null host fallback (single-tenant / staging)
     - rejection of non-token-shaped tails

3. docs/documenso-integration-audit.md updated with:
     - Phase 2/3/4/7 landed-work summary (replacing the old
       "deferred" list that was now stale)
     - Phase 5 coordination tracker for the marketing-website side
       (the four edits the website team needs to make — listed
       here so the CRM stays the source of truth on the contract)
     - Phase 6 polish backlog (auto-send delay, document expiration,
       per-document message, reminder display, failed-webhook UI,
       field metadata panel, zoom controls, recipient drag-reorder)

Tests: 21 new transformSigningUrl + signers tests across two files;
full suite 1340 → 1350 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:11:50 +02:00

19 KiB
Raw Blame History

Documenso integration audit

Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow (generate-quick-eoi.ts, documeso.ts, documenso.post.ts, website /sign/[type]/[token].vue) but rewired for multi-tenant + better-auth + Drizzle.


Per-port configuration

All Documenso settings live in system_settings keyed by (key, port_id) and are read via getPortDocumensoConfig(portId). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at /[portSlug]/admin/documenso.

Setting key Type Purpose
documenso_api_url_override string Per-port Documenso instance URL. Falls back to DOCUMENSO_API_URL env.
documenso_api_key_override string API key. Stored plaintext.
documenso_api_version_override 'v1' | 'v2' Different ports may run different Documenso versions.
documenso_eoi_template_id int Template ID for EOI generation.
documenso_client_recipient_id int Template recipient slot — client (signing order 1).
documenso_developer_recipient_id int Template recipient slot — developer (signing order 2).
documenso_approval_recipient_id int Template recipient slot — approver (signing order 3).
documenso_developer_name string Display name for developer signer (legacy hardcoded "David Mizrahi").
documenso_developer_email string Developer signer email.
documenso_approver_name string Approver display name.
documenso_approver_email string Approver email.
documenso_webhook_secret string Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal.
eoi_default_pathway 'documenso-template' | 'inapp' Which path is used when EOI is generated without explicit choice.
eoi_send_mode 'auto' | 'manual' Auto = send branded invitation email immediately; manual = rep clicks Send.
embedded_signing_host string Public host that wraps Documenso URLs into {host}/sign/<type>/<token>.
documenso_contract_template_id int (optional) Optional template for sales contracts. Blank = upload-and-place-fields per deal.
documenso_reservation_template_id int (optional) Optional template for reservation agreements. Same logic as contract.

Document type matrix

Type Generation flow Signers Field placement
EOI Documenso template (eoi_template_id) + form-fill values Static: client, developer, approver (per-port) Templated — fields baked into Documenso template
Contract Per-deal upload (drafted custom). Template fallback if configured Custom per deal — rep specifies Per-deal placement — default footer-anchored fallback
Reservation Per-deal upload OR template if configured Custom per deal Per-deal placement

Documenso field types

Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.

Field type Use case Needs fieldMeta? What goes in meta
SIGNATURE Drawn signature — almost every signing flow No
FREE_SIGNATURE Type-or-draw signature variant No
INITIALS Per-page initials block No
DATE Auto-fills the date when the recipient signs No
EMAIL Auto-fills the recipient's email No
NAME Auto-fills the recipient's name No
TEXT Free text input (e.g. address, notes, place of signing) Yes { text?, label?, required?, readOnly? }
NUMBER Numeric input with optional min/max Yes { numberFormat?, min?, max?, required? }
CHECKBOX Boolean / single checkbox Yes { values: [{ checked, value }], validationRule? }
DROPDOWN Pick from a fixed list Yes { values: [{ value }], defaultValue? }
RADIO Mutually-exclusive options Yes { values: [{ checked, value }] }

Helper: fieldTypeNeedsMeta(type) returns true for the configurable types so the placement UI knows when to surface a config side-panel.

fieldMeta is forwarded verbatim by placeFields() on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.


Documenso v1 vs v2 endpoint mapping

The documenso-client.ts abstracts both. Each function picks v1 or v2 from getPortDocumensoConfig(portId).apiVersion.

Operation v1 (1.131.32) v2.x
Create document from upload POST /api/v1/documents (body: { title, document, recipients }) POST /api/v2/envelope
Generate document from template POST /api/v1/templates/{id}/generate-document (template-from-envelope path)
Send for signing POST /api/v1/documents/{id}/send POST /api/v2/envelope/{id}/send
Place a field POST /api/v1/documents/{id}/fields (PIXEL coords, one at a time) POST /api/v2/envelope/field/create-many (PERCENT, bulk)
Get document state GET /api/v1/documents/{id} GET /api/v2/envelope/{id}
Send reminder to one recipient POST /api/v1/documents/{id}/recipients/{rid}/remind POST /api/v2/envelope/{id}/recipient/{rid}/remind
Download finalized PDF GET /api/v1/documents/{id}/download{ downloadUrl } then GET that URL GET /api/v2/envelope/{id}/download (same shape)
Cancel / void DELETE /api/v1/documents/{id} DELETE /api/v2/envelope/{id}
Healthcheck GET /api/v1/health (v1 path used)

Field key rename in v2 responses: iddocumentId and recipient idrecipientId. Our normalizeDocument() handles both shapes.


Signing-flow lifecycle

[rep clicks Generate]                       (CRM)
       │
       ▼
buildEoiContext(interestId, portId)         service
       │
       ▼
generateAndSign(templateId, ctx, signers)   creates Documenso doc
       │
       ▼
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
                                            it does NOT email signers
       │
       ▼
extract signing URLs from response          service
       │
       ▼
transformSigningUrl(url, host, role)        wrap as {host}/sign/<role>/<token>
       │
       ▼
if eoi_send_mode === 'auto':
    sendSigningInvitation(client)           our branded HTML email goes out
else:
    UI shows the URL + Send button          rep dispatches manually

When the client signs:

Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
       │
       ▼
verify x-documenso-secret (per-port lookup)
       │
       ▼
update document_signers row: status='signed', signedAt=...
       │
       ▼
if next signer in chain has not been notified:
    sendSigningInvitation(developer)        cascading "your turn" email

When the document reaches fully-signed:

Documenso fires DOCUMENT_COMPLETED webhook
       │
       ▼
download signed PDF from Documenso
       │
       ▼
store in storage backend → creates files row
       │
       ▼
update document: status='completed', completedAt=...
       │
       ▼
sendSigningCompleted([client, developer, approver], pdfFileId)
                                            all parties get the signed PDF
       │
       ▼
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)

Embedded signing on the marketing website

The CRM emits signing URLs in the form {embeddedSigningHost}/sign/<role>/<token>. The marketing website (Port Nimara/Website/pages/sign/[type]/[token].vue) hosts the page, embeds Documenso via @documenso/embed-vue's <EmbedSignDocument>, and POSTs back to the CRM webhook on completion.

For the embed to work, the Documenso instance MUST send Access-Control-Allow-Origin headers permitting the website origin.

nginx CORS block to apply on signatures.portnimara.dev

Add to the relevant server { ... } block:

location / {
    # CORS for embedded signing — allow the marketing-website origin
    # to load the Documenso signing iframe.
    add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;

    # Preflight
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }

    # ... your existing proxy_pass block to Documenso
}

To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:

set $cors_origin "";
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
    set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;

What's deferred vs landed in this build

Landed:

  • Per-port admin settings — every Documenso config knob is exposed at /admin/documenso
  • Branded invitation, completion, and reminder email templates
  • transformSigningUrl() for {host}/sign/<role>/<token> URL wrapping
  • Documenso v1 + v2 dual-version client (existing)
  • Webhook handler with timing-safe per-port secret resolution (existing)
  • Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
  • Stage-conditional tab visibility for EOI / Contract / Reservation

Landed in Phase 2-4 (2026-05-13):

  • Phase 2 — Webhook cascade + on-completion PDF distribution. handleRecipientSigned now finds the next pending signer and fires sendSigningInvitation; handleDocumentCompleted calls sendSigningCompleted to all recipients with the signed PDF attached (resolved via getStorageBackend() so MinIO + filesystem backends both work). Recipient matching prefers the Documenso recipient token captured at send-time (document_signers.signing_token); falls back to email match.
  • Phase 3lib/services/custom-document-upload.service.ts + POST /api/v1/interests/[id]/upload-for-signing. Magic-byte verifies the PDF, stores via getStorageBackend, inserts the documents row, runs the full Documenso round-trip (createDocument → sendDocument → placeFields), captures recipient tokens, auto-sends invitation when port sendMode === 'auto'.
  • Phase 4<UploadForSigningDialog> (src/components/documents/upload-for-signing-dialog.tsx). Three-step state machine (file → recipients → fields). Auto-detect runs server-side via lib/services/document-field-detector.ts (pdfjs text-extraction + anchor patterns); rep can drag/place/delete fields via native DOM events. Wired into the Contract + Reservation tabs.
  • Phase 7 — Project Director RBAC binding. Admin UI exposes documenso_developer_user_id / approver_user_id / _label settings; webhook cascade fires an in-CRM document_signing_your_turn notification for linked users alongside the email.

Phase 5 — Embedded signing URL emission verification:

  • transformSigningUrl() validated via 10 unit tests in tests/unit/services/document-signing-urls.test.ts. Maps signer-role → URL segment as:
    • client → /sign/client/<token>
    • developer → /sign/developer/<token>
    • approver → /sign/cc/<token> — funnels through the CC page with passive copy
    • witness → /sign/witness/<token> — website must handle this segment
    • other → /sign/cc/<token> — same as approver
  • Hardened to reject malformed source URLs: the function now uses extractSigningToken() (rejects tails <8 chars or with non-URL-safe punctuation), so a bare https://sig.example.com is returned untouched rather than producing the malformed <host>/sign/<role>/sig.example.com.

Phase 5 — coordination on the marketing-website side (NOT in this repo):

These are tracked here so the CRM stays the source of truth on the contract — the actual edits land in the website repo.

  1. Website /sign/[type]/[token].vue must handle type ∈ {client, cc, developer, witness}. The CRM emits cc for both approver and other roles, and witness for explicit witness signers. Anything else lands on the website's /sign/error fallback.
  2. signerMessages map must be keyed on (documentType, role) so a contract recipient hitting /sign/client/<token> sees "Sign Your Sales Contract" rather than the EOI default. Until the website is updated, the URL emits (role, token) only; the website can resolve documentType from the Documenso embed payload.
  3. Post-sign callback — the legacy portal POSTed to client-portal.portnimara.com/api/webhook/document-signed. The CRM no longer needs this — the Documenso webhook at /api/webhooks/documenso handles all state updates server-side. The website's POST is now optional; if it's still in place, point it at the CRM's webhook receiver as a real-time UI signal.
  4. Apply the nginx CORS block above on the prod Documenso instance.

Genuinely deferred (Phase 6 polish):

  • Auto-send delay (eoi_send_delay_minutes per-port setting + scheduled BullMQ job).
  • Document expiration toggle (documents.expires_at + Documenso expiresAt passthrough).
  • Per-document custom invitation message (textarea on the upload dialog → documents.invitation_message).
  • Reminder rate-limit display ("next reminder available in X days" badge on each unsigned signer in the signing-progress UI).
  • Failed-webhook recovery admin surface — the BullMQ webhook DLQ exists; needs an admin page with a Replay button.
  • Per-field metadata side panel for DROPDOWN/RADIO option lists in the Phase 4 dialog.
  • Pinch-zoom + zoom-out controls on the field-placement canvas.
  • Recipient drag-reorder via dnd-kit (current UI uses an order number input).

Manual ops work for you:

  • Apply the nginx CORS block above on your prod Documenso instance.
  • Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
  • Configure each port's developer/approver names and template IDs at /[portSlug]/admin/documenso.