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>
253 lines
19 KiB
Markdown
253 lines
19 KiB
Markdown
# 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](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.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)`](../src/lib/services/port-config.ts). 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)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
|
||
|
||
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) 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`](../src/lib/services/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()`](../src/lib/services/documenso-client.ts) 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](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.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:
|
||
|
||
```nginx
|
||
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:
|
||
|
||
```nginx
|
||
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 3** — `lib/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`.
|