Files
pn-new-crm/docs/documenso-integration-audit.md
Matt a0e68eb060 docs: comprehensive audits + Documenso build plan + admin UX backlog
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>
2026-05-07 20:57:53 +02:00

15 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

Deferred (separate sessions):

  • Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog.
  • Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs).
  • Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be computeDefaultSignatureLayout() (footer-anchored fields).
  • Webhook handler enhancements to track per-signer sent_at/opened_at/signed_at and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status.
  • Auto-store signed PDFs in storage backend and trigger sendSigningCompleted() on DOCUMENT_COMPLETED. Old system has this; needs porting.

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.