feat(documenso): full v2 endpoint coverage + sequential signing + redirectUrl

Wire up the remaining version-aware paths so a port pointed at Documenso 2.x
takes the v2 endpoint on every CRUD operation, with two new v2-only settings
exposed in admin UI.

documenso-client.ts:
- createDocument: v2 multipart /envelope/create + getDocument follow-up to
  return the full doc shape (v1 path unchanged)
- sendDocument: v2 /envelope/distribute (returns per-recipient signingUrl in
  the same response — eliminates the v1 separate-GET round-trip)
- sendReminder: v2 /envelope/redistribute with recipientIds filter
- downloadSignedPdf: v2 /envelope/{id}/download
- CreateDocumentMeta type: { subject, message, redirectUrl, signingOrder }
  threaded through v1 + v2 paths (v1 ignores signingOrder)

port-config.ts:
- New settings: documenso_signing_order (PARALLEL/SEQUENTIAL, v2-only),
  documenso_redirect_url (both versions honour)
- PortDocumensoConfig gains signingOrder + redirectUrl

documenso-payload.ts:
- DocumensoTemplatePayload.meta gains signingOrder
- buildDocumensoPayload reads from options.signingOrder, omits when null

document-templates.ts (EOI template flow):
- Pass docCfg.signingOrder + docCfg.redirectUrl into buildDocumensoPayload

documents.service.ts (sendForSigning uploaded-doc flow):
- Pass portId to documensoCreate + documensoSend (was missing)
- Thread signingOrder + redirectUrl via the new meta param

Admin Documenso settings page:
- v2 benefits card updated: now lists envelope CRUD, one-call send,
  sequential enforcement, post-sign redirect as wired (was roadmap)
- Roadmap callout pruned to the three remaining deferred items:
  template/use migration, /envelope/update, non-SIGNER recipient roles
- New "v2 signing behaviour" SettingsFormCard with the two new settings

Template flow stays on /api/v1/templates/{id}/generate-document by design —
Documenso 2.x accepts v1 endpoints via backward compat; full migration to
v2 /template/use requires per-template field-ID capture (admin schema work,
deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 14:38:45 +02:00
parent ad312df8a4
commit d597e158fe
8 changed files with 365 additions and 58 deletions

View File

@@ -90,6 +90,7 @@ src/
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time. - **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. `handleDocumentCompleted` is **idempotent** — early-returns when `doc.status === 'completed' && doc.signedFileId` so Documenso retries on 5xx don't insert duplicate file rows + orphan blobs. The switch handles `DOCUMENT_SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED`, plus v2 aliases `RECIPIENT_VIEWED` / `RECIPIENT_SIGNED` (logged + routed to v1 equivalents). - **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. `handleDocumentCompleted` is **idempotent** — early-returns when `doc.status === 'completed' && doc.signedFileId` so Documenso retries on 5xx don't insert duplicate file rows + orphan blobs. The switch handles `DOCUMENT_SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED`, plus v2 aliases `RECIPIENT_VIEWED` / `RECIPIENT_SIGNED` (logged + routed to v1 equivalents).
- **Documenso API responses:** 2.x renamed `id``documentId` and recipient `id``recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers. - **Documenso API responses:** 2.x renamed `id``documentId` and recipient `id``recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
- **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button.
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later). - **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified. - **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. - **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
@@ -107,6 +108,7 @@ src/
Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely). Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely).
Deploy: schema migration `0051_documents_hub_split.sql` ships the columns; `pnpm db:backfill:doc-folders` (script `scripts/backfill-document-folders.ts`) runs after the migration and is idempotent (per-port `pg_advisory_xact_lock`). Deploy: schema migration `0051_documents_hub_split.sql` ships the columns; `pnpm db:backfill:doc-folders` (script `scripts/backfill-document-folders.ts`) runs after the migration and is idempotent (per-port `pg_advisory_xact_lock`).
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware. - **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
- **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service. - **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service.
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit. - **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.

View File

@@ -29,7 +29,7 @@ const API_FIELDS: SettingFieldDef[] = [
key: 'documenso_api_version_override', key: 'documenso_api_version_override',
label: 'API version', label: 'API version',
description: description:
"Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).", 'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
type: 'select', type: 'select',
options: [ options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' }, { value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
@@ -144,6 +144,30 @@ const EMBED_FIELDS: SettingFieldDef[] = [
}, },
]; ];
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
description:
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 — v1 instances ignore this and always behave as PARALLEL.',
type: 'select',
options: [
{ value: '', label: 'PARALLEL (default)' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
],
defaultValue: '',
},
{
key: 'documenso_redirect_url',
label: 'Post-signing redirect URL',
description:
"URL Documenso redirects the signer to after they complete signing. Typically the marketing site's success page so signers land on a branded thank-you rather than Documenso's own page. Leave blank to use Documenso's default. v1 and v2 both honour this. Example: https://portnimara.com/sign/success",
type: 'string',
placeholder: 'https://portnimara.com/sign/success',
defaultValue: '',
},
];
export default function DocumensoSettingsPage() { export default function DocumensoSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -162,10 +186,10 @@ export default function DocumensoSettingsPage() {
<CardContent className="space-y-4 text-sm"> <CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
backwards compatibility. v2 is recommended for new ports and unlocks the features backwards compatibility. v2 is recommended for new ports and unlocks the features below.
below. Switching versions does <strong>not</strong> require any code changes Switching versions does <strong>not</strong> require any code changes version-aware
version-aware client methods pick the right endpoint per port. Switch, save, then run client methods pick the right endpoint per port. Switch, save, then run the
the test-connection button to confirm the chosen instance is actually on the matching test-connection button to confirm the chosen instance is actually on the matching
Documenso version. Documenso version.
</p> </p>
@@ -175,7 +199,10 @@ export default function DocumensoSettingsPage() {
</p> </p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600" aria-hidden="true" /> <CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span> <span>
<strong>Bulk field placement.</strong> One API call per envelope vs. v1&apos;s <strong>Bulk field placement.</strong> One API call per envelope vs. v1&apos;s
per-field POST loop. Faster contract generation, fewer transient retries on per-field POST loop. Faster contract generation, fewer transient retries on
@@ -183,23 +210,32 @@ export default function DocumensoSettingsPage() {
</span> </span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600" aria-hidden="true" /> <CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span> <span>
<strong>Percent-based field coordinates.</strong> No page-dimension lookup <strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
needed coordinates are portable across page sizes. v1 requires us to assume A4 coordinates are portable across page sizes. v1 requires us to assume A4 for
for auto-placed fields. auto-placed fields.
</span> </span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600" aria-hidden="true" /> <CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span> <span>
<strong>Richer field metadata.</strong> TEXT labels &amp; required flags, NUMBER <strong>Richer field metadata.</strong> TEXT labels &amp; required flags, NUMBER
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults all min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults all ignored
ignored by v1, surfaced by v2 in the signing UI. by v1, surfaced by v2 in the signing UI.
</span> </span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600" aria-hidden="true" /> <CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span> <span>
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '} <strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '} <code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
@@ -208,11 +244,54 @@ export default function DocumensoSettingsPage() {
</span> </span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600" aria-hidden="true" /> <CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span> <span>
<strong>Envelope/embed endpoints.</strong> <code>GET</code> and{' '} <strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
<code>DELETE</code> go through <code>/api/v2/envelope/...</code> when v2 is <code>POST /envelope/create</code> (multipart), <code>POST /envelope/distribute</code>,{' '}
selected. Future embedded-signing iframe work will plug in here. <code>POST /envelope/redistribute</code>, <code>GET /envelope/{'{id}'}/download</code>{' '}
all routed through <code>/api/v2/envelope/...</code> when v2 is selected. The
template-generate path is intentionally still v1 (relies on Documenso 2.x&apos;s
backward-compat window see the deferred-roadmap below).
</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span>
<strong>One-call send.</strong> v2&apos;s <code>/envelope/distribute</code> returns
per-recipient <code>signingUrl</code> in the same response v1 requires a
separate GET to fetch them. Faster send flow on the rep side.
</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span>
<strong>Sequential signing enforcement.</strong> Pick SEQUENTIAL in the &quot;v2
signing behaviour&quot; card below and Documenso 2.x refuses to email recipient
N+1 until recipient N has signed. Eliminates the &quot;approver signed before the
developer did&quot; race on EOIs.
</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2
className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-600"
aria-hidden="true"
/>
<span>
<strong>Post-signing redirect URL.</strong> Set in the &quot;v2 signing
behaviour&quot; card; Documenso redirects the signer to that URL after they
complete signing. Use to land clients on the marketing site&apos;s success page
or back in the portal instead of Documenso&apos;s default thank-you page. (v1
honours this too listed here because the admin setting was added with the v2
work.)
</span> </span>
</li> </li>
</ul> </ul>
@@ -220,35 +299,35 @@ export default function DocumensoSettingsPage() {
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/30"> <div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/30">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400"> <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
v2 capabilities on the roadmap (not yet wired) v2 capabilities deferred (would need new code paths)
</p> </p>
<ul className="space-y-1.5 text-muted-foreground"> <ul className="space-y-1.5 text-muted-foreground">
<li> <li>
<strong>Sequential signing</strong> (<code>signingOrder: SEQUENTIAL</code>) would <strong>
force client developer approver order on EOIs instead of all-at-once. Single-shot <code>/template/use</code>
</strong>{' '}
with v2 <code>prefillFields</code> by ID current EOI flow uses{' '}
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
<code>formValues</code> keyed by name. v2 instances accept both during their
backward-compat window; full migration requires per-template field-ID capture in
admin settings.
</li> </li>
<li> <li>
<strong>Post-signing redirect URL</strong> (<code>redirectUrl</code>) would land <strong>
signed clients back on the portal rather than Documenso&apos;s page. Update envelope metadata after creation (<code>/envelope/update</code>)
</strong>{' '}
change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
re-generating.
</li> </li>
<li> <li>
<strong>Single-shot <code>/template/use</code></strong> (v2 prefillFields by ID <strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> APPROVER role is
replacing v1 formValues by name) currently the EOI flow still uses the v1 already used by the EOI template; CC + VIEWER not yet exposed in the recipient
template path even when API version is v2. Needs per-template field-ID mapping in builder. Useful for sales managers who want a copy without a signature slot.
the template config before we can switch.
</li>
<li>
<strong>Update envelope metadata</strong> (<code>/envelope/update</code>) change
title / subject / redirectUrl after creation without re-generating.
</li>
<li>
<strong>Recipient roles beyond SIGNER</strong> (APPROVER / CC / VIEWER) would let
sales managers receive copies without a signature slot.
</li> </li>
</ul> </ul>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
These items have no admin setting yet because they need code changes first. They Sequential signing and post-signing redirect URL <strong>are now wired</strong>
live here so you know what&apos;s in the pipeline. see the new &quot;v2 signing behaviour&quot; card below to configure them.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -261,6 +340,12 @@ export default function DocumensoSettingsPage() {
extra={<DocumensoTestButton />} extra={<DocumensoTestButton />}
/> />
<SettingsFormCard
title="v2 signing behaviour"
description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances."
fields={V2_FEATURE_FIELDS}
/>
<SettingsFormCard <SettingsFormCard
title="Signers (developer + approver)" title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots." description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."

View File

@@ -154,11 +154,26 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
return out; return out;
} }
/**
* Optional metadata applied to the document on creation. v1 accepts
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
* v2's `/envelope/create` accepts the same plus `signingOrder` for
* PARALLEL-vs-SEQUENTIAL signing enforcement.
*/
export interface CreateDocumentMeta {
subject?: string;
message?: string;
redirectUrl?: string;
/** v2 only. v1 ignores. */
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}
export async function createDocument( export async function createDocument(
title: string, title: string,
pdfBase64: string, pdfBase64: string,
recipients: DocumensoRecipient[], recipients: DocumensoRecipient[],
portId?: string, portId?: string,
meta?: CreateDocumentMeta,
): Promise<DocumensoDocument> { ): Promise<DocumensoDocument> {
const safeRecipients = applyRecipientRedirect(recipients); const safeRecipients = applyRecipientRedirect(recipients);
if (env.EMAIL_REDIRECT_TO) { if (env.EMAIL_REDIRECT_TO) {
@@ -167,11 +182,100 @@ export async function createDocument(
'Documenso recipients redirected to EMAIL_REDIRECT_TO', 'Documenso recipients redirected to EMAIL_REDIRECT_TO',
); );
} }
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2: multipart /envelope/create with payload + files. Convert the
// base64 PDF to a Buffer and ship it under `files`. Returns
// `{ id: envelopeId }` only — caller distributes separately via
// sendDocument(envelopeId).
const { baseUrl, apiKey } = await resolveCreds(portId);
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
const form = new FormData();
const payload = {
type: 'DOCUMENT',
title,
recipients: safeRecipients.map((r, i) => ({
email: r.email,
name: r.name,
role: r.role,
signingOrder: r.signingOrder || i + 1,
})),
...(meta
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}),
},
}
: {}),
};
form.append('payload', JSON.stringify(payload));
form.append(
'files',
new Blob([pdfBuffer], { type: 'application/pdf' }),
`${title.replace(/[^a-z0-9-_]+/gi, '-')}.pdf`,
);
let res: Response;
try {
res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `/api/v2/envelope/create timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!res.ok) {
const errText = await res.text();
logger.error(
{ status: res.status, err: errText, portId },
'Documenso v2 envelope/create error',
);
if (res.status === 401 || res.status === 403) {
throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
internalMessage: `v2 envelope/create → ${res.status}`,
});
}
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v2 envelope/create → ${res.status}: ${errText}`,
});
}
const created = (await res.json()) as Record<string, unknown>;
// v2 returns just `{ id }`. Re-fetch the full envelope so the
// caller gets recipients (without signing URLs — those come after
// distribute). Keeps shape identical to v1's createDocument response.
const envelopeId = String(created.id ?? created.documentId ?? '');
return getDocument(envelopeId, portId);
}
// v1: existing path. Meta keys are accepted at the top level.
return documensoFetch( return documensoFetch(
'/api/v1/documents', '/api/v1/documents',
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }), body: JSON.stringify({
title,
document: pdfBase64,
recipients: safeRecipients,
...(meta?.subject || meta?.message || meta?.redirectUrl
? {
meta: {
...(meta.subject ? { subject: meta.subject } : {}),
...(meta.message ? { message: meta.message } : {}),
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
},
}
: {}),
}),
}, },
portId, portId,
).then(normalizeDocument); ).then(normalizeDocument);
@@ -219,6 +323,34 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
// Documenso's perspective. // Documenso's perspective.
return getDocument(docId, portId); return getDocument(docId, portId);
} }
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
// Returns the envelope with per-recipient signingUrl fields populated —
// this is one of the genuine v2 wins (saves a separate GET round-trip).
const distributed = (await documensoFetch(
'/api/v2/envelope/distribute',
{
method: 'POST',
body: JSON.stringify({ envelopeId: docId }),
},
portId,
)) as Record<string, unknown>;
// Distribute response shape: { success, id, recipients: [...] }.
// The recipients carry name/email/token/role/signingOrder/signingUrl.
// Normalize by re-wrapping into the document shape that downstream
// callers already consume.
return normalizeDocument({
id: distributed.id,
// v2 doesn't return `status` on the distribute response — the call
// itself moves the envelope from DRAFT to PENDING, so PENDING is
// the correct authoritative state.
status: 'PENDING',
recipients: distributed.recipients,
});
}
return documensoFetch( return documensoFetch(
`/api/v1/documents/${docId}/send`, `/api/v1/documents/${docId}/send`,
{ {
@@ -254,6 +386,25 @@ export async function sendReminder(
); );
return; return;
} }
const { apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
// v2 sends reminders via redistribute. Documenso 2.x doesn't expose a
// recipient-targeted reminder endpoint directly; instead /envelope/redistribute
// resends to all pending recipients on the envelope. Single-recipient
// targeting requires admin-side filtering. For now we redistribute the
// entire envelope, which is functionally equivalent for the typical
// case (most reminders go to the one outstanding signer).
await documensoFetch(
'/api/v2/envelope/redistribute',
{
method: 'POST',
body: JSON.stringify({ envelopeId: docId, recipientIds: [signerId] }),
},
portId,
);
return;
}
await documensoFetch( await documensoFetch(
`/api/v1/documents/${docId}/recipients/${signerId}/remind`, `/api/v1/documents/${docId}/recipients/${signerId}/remind`,
{ {
@@ -264,8 +415,13 @@ export async function sendReminder(
} }
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> { export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
const { baseUrl, apiKey } = await resolveCreds(portId); const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
const path = `/api/v1/documents/${docId}/download`; // v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
// envelope namespace). v1: existing /documents/{id}/download.
const path =
apiVersion === 'v2'
? `/api/v2/envelope/${docId}/download`
: `/api/v1/documents/${docId}/download`;
let res: Response; let res: Response;
try { try {
res = await fetchWithTimeout(`${baseUrl}${path}`, { res = await fetchWithTimeout(`${baseUrl}${path}`, {

View File

@@ -12,6 +12,13 @@ export interface DocumensoTemplatePayload {
subject: string; subject: string;
redirectUrl: string; redirectUrl: string;
distributionMethod: 'NONE' | 'EMAIL'; distributionMethod: 'NONE' | 'EMAIL';
/**
* PARALLEL = all signers can sign in any order (default, current behaviour).
* SEQUENTIAL = signers must complete in the order their `signingOrder`
* number dictates (client → developer → approver for EOI). v2 enforces
* this server-side; v1 ignores the key and behaves as PARALLEL regardless.
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}; };
formValues: { formValues: {
Name: string; Name: string;
@@ -54,6 +61,11 @@ export interface DocumensoPayloadOptions {
approverEmail?: string; approverEmail?: string;
/** Redirect URL after signing. Defaults to the app URL. */ /** Redirect URL after signing. Defaults to the app URL. */
redirectUrl?: string; redirectUrl?: string;
/**
* PARALLEL (default) or SEQUENTIAL — v2-only enforcement (v1 ignores).
* Set via per-port `documenso_signing_order` system_settings key.
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
} }
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi'; const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
@@ -129,6 +141,7 @@ export function buildDocumensoPayload(
subject: 'Your LOI is ready to be signed', subject: 'Your LOI is ready to be signed',
redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL, redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL,
distributionMethod: 'NONE', distributionMethod: 'NONE',
...(options.signingOrder ? { signingOrder: options.signingOrder } : {}),
}, },
formValues: { formValues: {
Name: context.client.fullName, Name: context.client.fullName,

View File

@@ -902,7 +902,11 @@ async function generateAndSignViaDocumensoTemplate(
developerEmail: signers.developer.email, developerEmail: signers.developer.email,
approverName: signers.approver.name, approverName: signers.approver.name,
approverEmail: signers.approver.email, approverEmail: signers.approver.email,
redirectUrl: env.APP_URL, // Prefer per-port post-signing redirect (typically marketing-site
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
// v2-only signing-order enforcement. v1 instances ignore this key.
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
}); });
const documensoDoc = await documensoGenerateFromTemplate( const documensoDoc = await documensoGenerateFromTemplate(

View File

@@ -34,6 +34,7 @@ import {
voidDocument as documensoVoid, voidDocument as documensoVoid,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload'; import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { import {
listTree, listTree,
collectDescendantIds, collectDescendantIds,
@@ -699,24 +700,39 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const pdfBuffer = Buffer.concat(chunks); const pdfBuffer = Buffer.concat(chunks);
const pdfBase64 = pdfBuffer.toString('base64'); const pdfBase64 = pdfBuffer.toString('base64');
// Create document in Documenso + send // Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL).
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ // Both are optional — passing undefined yields v1's legacy behavior.
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, const docCfg = await getPortDocumensoConfig(portId);
{
name: eoiSigners.developer.name,
email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER',
signingOrder: 3,
},
]);
await documensoSend(documensoDoc.id); // Create document in Documenso + send. portId is required for the v2
// envelope/create code path (which routes by per-port apiVersion);
// meta.signingOrder is honoured only on v2 instances.
const documensoDoc = await documensoCreate(
doc.title,
pdfBase64,
[
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{
name: eoiSigners.developer.name,
email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER',
signingOrder: 3,
},
],
portId,
{
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
},
);
await documensoSend(documensoDoc.id, portId);
// Update signer records with signing URLs from Documenso response // Update signer records with signing URLs from Documenso response
for (const docSigner of documensoDoc.recipients) { for (const docSigner of documensoDoc.recipients) {

View File

@@ -77,6 +77,16 @@ export const SETTING_KEYS = {
// uses templates rather than per-deal uploads. Optional. // uses templates rather than per-deal uploads. Optional.
documensoContractTemplateId: 'documenso_contract_template_id', documensoContractTemplateId: 'documenso_contract_template_id',
documensoReservationTemplateId: 'documenso_reservation_template_id', documensoReservationTemplateId: 'documenso_reservation_template_id',
// v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement on
// multi-recipient envelopes. When SEQUENTIAL is set + apiVersion=v2,
// Documenso refuses to email recipient N+1 until recipient N has signed.
// Ignored entirely on v1 instances.
documensoSigningOrder: 'documenso_signing_order',
// v2-only override of the post-signing redirect URL set on documentMeta.
// Falls back to the embedded signing host (or APP_URL) when unset. Use
// this to land signed clients on /portal/eoi-complete (or wherever
// makes sense for the workflow).
documensoRedirectUrl: 'documenso_redirect_url',
// Branding // Branding
brandingLogoUrl: 'branding_logo_url', brandingLogoUrl: 'branding_logo_url',
@@ -222,6 +232,19 @@ export interface PortDocumensoConfig {
* user's email for in-CRM signing-status updates. */ * user's email for in-CRM signing-status updates. */
developerUserId: string | null; developerUserId: string | null;
approverUserId: string | null; approverUserId: string | null;
/**
* v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement.
* `null` keeps the upstream default (PARALLEL); a non-null value gets
* passed verbatim. v1 instances ignore this — see admin Documenso page.
*/
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
/**
* v2-only: post-signing redirect URL set on documentMeta. When null,
* the upstream Documenso default applies (Documenso's own thank-you
* page). Typically set to `{embeddedSigningHost}/sign/success` so
* signers land back on the branded marketing site.
*/
redirectUrl: string | null;
} }
function toIntOrNull(raw: unknown): number | null { function toIntOrNull(raw: unknown): number | null {
@@ -255,6 +278,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approverLabel, approverLabel,
developerUserId, developerUserId,
approverUserId, approverUserId,
signingOrder,
redirectUrlOverride,
] = await Promise.all([ ] = await Promise.all([
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId), readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId), readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
@@ -276,6 +301,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId), readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId), readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId), readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
]); ]);
return { return {
@@ -299,6 +326,8 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approverLabel: approverLabel ?? 'Approver', approverLabel: approverLabel ?? 'Approver',
developerUserId: developerUserId ?? null, developerUserId: developerUserId ?? null,
approverUserId: approverUserId ?? null, approverUserId: approverUserId ?? null,
signingOrder: signingOrder ?? null,
redirectUrl: redirectUrlOverride ?? null,
}; };
} }

View File

@@ -53,6 +53,8 @@ function configurePort(version: 'v1' | 'v2'): void {
approverLabel: 'Approver', approverLabel: 'Approver',
developerUserId: null, developerUserId: null,
approverUserId: null, approverUserId: null,
signingOrder: null,
redirectUrl: null,
}); });
} }