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:
@@ -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.
|
||||||
|
|||||||
@@ -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's
|
<strong>Bulk field placement.</strong> One API call per envelope vs. v1'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 & required flags, NUMBER
|
<strong>Richer field metadata.</strong> TEXT labels & 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'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'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 "v2
|
||||||
|
signing behaviour" card below and Documenso 2.x refuses to email recipient
|
||||||
|
N+1 until recipient N has signed. Eliminates the "approver signed before the
|
||||||
|
developer did" 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 "v2 signing
|
||||||
|
behaviour" card; Documenso redirects the signer to that URL after they
|
||||||
|
complete signing. Use to land clients on the marketing site's success page
|
||||||
|
or back in the portal instead of Documenso'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'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's in the pipeline.
|
see the new "v2 signing behaviour" 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."
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user