fix(signing): branded embed URL for developer + Copy link
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m6s

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/<token>`. 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 15:14:28 +02:00
parent 2bc2cfac6f
commit 64a488dc15
3 changed files with 39 additions and 3 deletions

View File

@@ -131,9 +131,14 @@ export interface SigningCompletedArgs {
* Risk #5 - fixing this mapping prevents an `approver` invite from * Risk #5 - fixing this mapping prevents an `approver` invite from
* landing on `/sign/error`. * landing on `/sign/error`.
*/ */
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer' | 'witness'> = { const ROLE_TO_URL_SEGMENT: Record<string, 'client' | 'cc' | 'developer' | 'witness'> = {
client: 'client', client: 'client',
developer: 'developer', 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/<token>` (dead). Map it to the developer page.
signer: 'developer',
approver: 'cc', approver: 'cc',
witness: 'witness', witness: 'witness',
other: 'cc', other: 'cc',
@@ -155,7 +160,9 @@ export function transformSigningUrl(
// Trim trailing slashes off the host so we always produce a clean // Trim trailing slashes off the host so we always produce a clean
// single `/` between segments. // single `/` between segments.
const host = embeddedSigningHost.replace(/\/+$/, ''); 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/<token>`.
const urlRole = ROLE_TO_URL_SEGMENT[signerRole] ?? 'cc';
return `${host}/sign/${urlRole}/${token}`; return `${host}/sign/${urlRole}/${token}`;
} }

View File

@@ -46,6 +46,7 @@ import {
sendSigningInvitation, sendSigningInvitation,
sendSigningCompleted, sendSigningCompleted,
sendSigningStatusNotification, sendSigningStatusNotification,
transformSigningUrl,
type SignerRole, type SignerRole,
} from '@/lib/services/document-signing-emails.service'; } from '@/lib/services/document-signing-emails.service';
import { import {
@@ -1063,10 +1064,23 @@ export async function uploadSignedManually(
export async function listDocumentSigners(documentId: string, portId: string) { export async function listDocumentSigners(documentId: string, portId: string) {
await getDocumentById(documentId, portId); // verify access await getDocumentById(documentId, portId); // verify access
return db.query.documentSigners.findMany({ const rows = await db.query.documentSigners.findMany({
where: eq(documentSigners.documentId, documentId), where: eq(documentSigners.documentId, documentId),
orderBy: (ds, { asc }) => [asc(ds.signingOrder)], 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 ────────────────────────────────────────────────────────────── // ─── List Events ──────────────────────────────────────────────────────────────

View File

@@ -44,6 +44,21 @@ describe('transformSigningUrl', () => {
); );
}); });
it("maps 'signer' (Documenso's persisted order-2 role) → /sign/developer/<token>", () => {
// 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/<token>` invitation links.
expect(transformSigningUrl(RAW, HOST, 'signer' as never)).toBe(
'https://portnimara.com/sign/developer/vbT8hi3jKQmrFP_LN1WcS',
);
});
it('falls back to /sign/cc/<token> 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/<token>', () => { it('maps witness → /sign/witness/<token>', () => {
expect(transformSigningUrl(RAW, HOST, 'witness')).toBe( expect(transformSigningUrl(RAW, HOST, 'witness')).toBe(
'https://portnimara.com/sign/witness/vbT8hi3jKQmrFP_LN1WcS', 'https://portnimara.com/sign/witness/vbT8hi3jKQmrFP_LN1WcS',