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>
15 KiB
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.13–1.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: id → documentId and recipient id → recipientId. 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_atand trigger the cascading "your turn" branded emails. Currently the webhook just updates document status. - Auto-store signed PDFs in storage backend and trigger
sendSigningCompleted()onDOCUMENT_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.