diff --git a/CLAUDE.md b/CLAUDE.md index 000b2f94..6d6a2723 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **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 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 `` 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 `` (`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 `` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1//[id]/tags` endpoint backed by a `setTags` 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). 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. - **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. diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx index 185478c5..694740bc 100644 --- a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx @@ -29,7 +29,7 @@ const API_FIELDS: SettingFieldDef[] = [ key: 'documenso_api_version_override', label: 'API version', 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', options: [ { 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() { return (
@@ -162,10 +186,10 @@ export default function DocumensoSettingsPage() {

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 - below. Switching versions does not require any code changes — - version-aware client methods pick the right endpoint per port. Switch, save, then run - the test-connection button to confirm the chosen instance is actually on the matching + backwards compatibility. v2 is recommended for new ports and unlocks the features below. + Switching versions does not require any code changes — version-aware + client methods pick the right endpoint per port. Switch, save, then run the + test-connection button to confirm the chosen instance is actually on the matching Documenso version.

@@ -175,7 +199,10 @@ export default function DocumensoSettingsPage() {

  • -
  • -
  • -
  • -
  • -
  • +
  • +
  • +
  • +
  • +
  • +
@@ -220,35 +299,35 @@ export default function DocumensoSettingsPage() {

- v2 capabilities on the roadmap (not yet wired) + v2 capabilities deferred (would need new code paths)

  • - Sequential signing (signingOrder: SEQUENTIAL) — would - force client → developer → approver order on EOIs instead of all-at-once. + + Single-shot /template/use + {' '} + with v2 prefillFields by ID — current EOI flow uses{' '} + /api/v1/templates/{'{id}'}/generate-document with{' '} + formValues keyed by name. v2 instances accept both during their + backward-compat window; full migration requires per-template field-ID capture in + admin settings.
  • - Post-signing redirect URL (redirectUrl) — would land - signed clients back on the portal rather than Documenso's page. + + Update envelope metadata after creation (/envelope/update) + {' '} + — change title / subject / redirectUrl on a doc already in DRAFT/PENDING without + re-generating.
  • - Single-shot /template/use (v2 prefillFields by ID - replacing v1 formValues by name) — currently the EOI flow still uses the v1 - template path even when API version is v2. Needs per-template field-ID mapping in - the template config before we can switch. -
  • -
  • - Update envelope metadata (/envelope/update) — change - title / subject / redirectUrl after creation without re-generating. -
  • -
  • - Recipient roles beyond SIGNER (APPROVER / CC / VIEWER) — would let - sales managers receive copies without a signature slot. + Non-SIGNER recipient roles (CC / VIEWER) — APPROVER role is + already used by the EOI template; CC + VIEWER not yet exposed in the recipient + builder. Useful for sales managers who want a copy without a signature slot.

- These items have no admin setting yet because they need code changes first. They - live here so you know what's in the pipeline. + Sequential signing and post-signing redirect URL are now wired — + see the new "v2 signing behaviour" card below to configure them.

@@ -261,6 +340,12 @@ export default function DocumensoSettingsPage() { extra={} /> + + ): Record { const safeRecipients = applyRecipientRedirect(recipients); if (env.EMAIL_REDIRECT_TO) { @@ -167,11 +182,100 @@ export async function createDocument( '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; + // 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( '/api/v1/documents', { 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, ).then(normalizeDocument); @@ -219,6 +323,34 @@ export async function sendDocument(docId: string, portId?: string): Promise; + // 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( `/api/v1/documents/${docId}/send`, { @@ -254,6 +386,25 @@ export async function sendReminder( ); 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( `/api/v1/documents/${docId}/recipients/${signerId}/remind`, { @@ -264,8 +415,13 @@ export async function sendReminder( } export async function downloadSignedPdf(docId: string, portId?: string): Promise { - const { baseUrl, apiKey } = await resolveCreds(portId); - const path = `/api/v1/documents/${docId}/download`; + const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); + // 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; try { res = await fetchWithTimeout(`${baseUrl}${path}`, { diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts index ef20c438..22b66f31 100644 --- a/src/lib/services/documenso-payload.ts +++ b/src/lib/services/documenso-payload.ts @@ -12,6 +12,13 @@ export interface DocumensoTemplatePayload { subject: string; redirectUrl: string; 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: { Name: string; @@ -54,6 +61,11 @@ export interface DocumensoPayloadOptions { approverEmail?: string; /** Redirect URL after signing. Defaults to the app URL. */ 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'; @@ -129,6 +141,7 @@ export function buildDocumensoPayload( subject: 'Your LOI is ready to be signed', redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL, distributionMethod: 'NONE', + ...(options.signingOrder ? { signingOrder: options.signingOrder } : {}), }, formValues: { Name: context.client.fullName, diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 864d1557..8b75fb90 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -902,7 +902,11 @@ async function generateAndSignViaDocumensoTemplate( developerEmail: signers.developer.email, approverName: signers.approver.name, 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( diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index e771e309..947a133e 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -34,6 +34,7 @@ import { voidDocument as documensoVoid, } from '@/lib/services/documenso-client'; import { getPortEoiSigners } from '@/lib/services/documenso-payload'; +import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { listTree, collectDescendantIds, @@ -699,24 +700,39 @@ export async function sendForSigning(documentId: string, portId: string, meta: A const pdfBuffer = Buffer.concat(chunks); const pdfBase64 = pdfBuffer.toString('base64'); - // Create document in Documenso + send - 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, - }, - ]); + // Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL). + // Both are optional — passing undefined yields v1's legacy behavior. + const docCfg = await getPortDocumensoConfig(portId); - 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 for (const docSigner of documensoDoc.recipients) { diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 675632d1..6e03ef4b 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -77,6 +77,16 @@ export const SETTING_KEYS = { // uses templates rather than per-deal uploads. Optional. documensoContractTemplateId: 'documenso_contract_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 brandingLogoUrl: 'branding_logo_url', @@ -222,6 +232,19 @@ export interface PortDocumensoConfig { * user's email for in-CRM signing-status updates. */ developerUserId: 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 { @@ -255,6 +278,8 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoApiUrlOverride, portId), readSetting(SETTING_KEYS.documensoApiKeyOverride, portId), @@ -276,6 +301,8 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoApproverLabel, portId), readSetting(SETTING_KEYS.documensoDeveloperUserId, portId), readSetting(SETTING_KEYS.documensoApproverUserId, portId), + readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId), + readSetting(SETTING_KEYS.documensoRedirectUrl, portId), ]); return { @@ -299,6 +326,8 @@ export async function getPortDocumensoConfig(portId: string): Promise