From 64a488dc15073aafd21a3a1470ef951df0c1c696 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jun 2026 15:14:28 +0200 Subject: [PATCH] fix(signing): branded embed URL for developer + Copy link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the marketing-site embed-URL rewrite, both surfaced by testing through the actual invitation emails: 1. Developer invite linked to `…/sign/undefined/`. document_signers persists Documenso's normalized role, so the order-2 EOI developer arrives as 'signer', which ROLE_TO_URL_SEGMENT didn't have — the lookup returned undefined. Add a 'signer' → 'developer' alias and a 'cc' fallback so an unknown role can never emit `/sign/undefined/`. 2. "Copy link" copied the bare Documenso URL, not the branded embed URL. listDocumentSigners (feeds the EOI tab signers + Copy link) now runs each signing_url through transformSigningUrl, so the copied link matches what the invitation email sends. Send-invitation/automation read the raw rows directly, so they're unaffected. Regression tests pin 'signer' → /sign/developer and the unknown-role fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/document-signing-emails.service.ts | 11 +++++++++-- src/lib/services/documents.service.ts | 16 +++++++++++++++- .../unit/services/document-signing-urls.test.ts | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/lib/services/document-signing-emails.service.ts b/src/lib/services/document-signing-emails.service.ts index 4d69c16e..a78d9571 100644 --- a/src/lib/services/document-signing-emails.service.ts +++ b/src/lib/services/document-signing-emails.service.ts @@ -131,9 +131,14 @@ export interface SigningCompletedArgs { * Risk #5 - fixing this mapping prevents an `approver` invite from * landing on `/sign/error`. */ -const ROLE_TO_URL_SEGMENT: Record = { +const ROLE_TO_URL_SEGMENT: Record = { client: 'client', developer: 'developer', + // `document_signers.signer_role` persists Documenso's normalized role, so + // the order-2 EOI developer arrives here as 'signer' (not 'developer'). + // Without this alias the lookup returned `undefined` and the branded link + // became `…/sign/undefined/` (dead). Map it to the developer page. + signer: 'developer', approver: 'cc', witness: 'witness', other: 'cc', @@ -155,7 +160,9 @@ export function transformSigningUrl( // Trim trailing slashes off the host so we always produce a clean // single `/` between segments. const host = embeddedSigningHost.replace(/\/+$/, ''); - const urlRole = ROLE_TO_URL_SEGMENT[signerRole]; + // Fall back to the passive `cc` page for any unrecognised role rather than + // ever emitting `…/sign/undefined/`. + const urlRole = ROLE_TO_URL_SEGMENT[signerRole] ?? 'cc'; return `${host}/sign/${urlRole}/${token}`; } diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 04bea46b..e143a4b0 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -46,6 +46,7 @@ import { sendSigningInvitation, sendSigningCompleted, sendSigningStatusNotification, + transformSigningUrl, type SignerRole, } from '@/lib/services/document-signing-emails.service'; import { @@ -1063,10 +1064,23 @@ export async function uploadSignedManually( export async function listDocumentSigners(documentId: string, portId: string) { await getDocumentById(documentId, portId); // verify access - return db.query.documentSigners.findMany({ + const rows = await db.query.documentSigners.findMany({ where: eq(documentSigners.documentId, documentId), orderBy: (ds, { asc }) => [asc(ds.signingOrder)], }); + + // Surface the BRANDED marketing-site embed URL (the same wrap the + // invitation email applies) rather than the bare Documenso link, so the + // EOI tab's "Copy link" shares the on-brand signing page. No-op when the + // port has no embeddedSigningHost configured (transformSigningUrl returns + // the raw URL unchanged). + const { embeddedSigningHost } = await getPortDocumensoConfig(portId); + return rows.map((r) => ({ + ...r, + signingUrl: r.signingUrl + ? transformSigningUrl(r.signingUrl, embeddedSigningHost, r.signerRole as SignerRole) + : r.signingUrl, + })); } // ─── List Events ────────────────────────────────────────────────────────────── diff --git a/tests/unit/services/document-signing-urls.test.ts b/tests/unit/services/document-signing-urls.test.ts index 181a0129..b91672a0 100644 --- a/tests/unit/services/document-signing-urls.test.ts +++ b/tests/unit/services/document-signing-urls.test.ts @@ -44,6 +44,21 @@ describe('transformSigningUrl', () => { ); }); + it("maps 'signer' (Documenso's persisted order-2 role) → /sign/developer/", () => { + // document_signers.signer_role stores Documenso's normalized role, so the + // EOI developer arrives as 'signer'. Regression: this used to fall through + // to `undefined` → dead `…/sign/undefined/` invitation links. + expect(transformSigningUrl(RAW, HOST, 'signer' as never)).toBe( + 'https://portnimara.com/sign/developer/vbT8hi3jKQmrFP_LN1WcS', + ); + }); + + it('falls back to /sign/cc/ for any unrecognised role (never undefined)', () => { + expect(transformSigningUrl(RAW, HOST, 'mystery-role' as never)).toBe( + 'https://portnimara.com/sign/cc/vbT8hi3jKQmrFP_LN1WcS', + ); + }); + it('maps witness → /sign/witness/', () => { expect(transformSigningUrl(RAW, HOST, 'witness')).toBe( 'https://portnimara.com/sign/witness/vbT8hi3jKQmrFP_LN1WcS',