Skip to content

feat(documents): add Documenso signing flow#424

Open
Connorbelez wants to merge 8 commits intographite-base/424from
eng-289
Open

feat(documents): add Documenso signing flow#424
Connorbelez wants to merge 8 commits intographite-base/424from
eng-289

Conversation

@Connorbelez
Copy link
Copy Markdown
Owner

@Connorbelez Connorbelez commented Apr 20, 2026

feat(documents): add Documenso signing flow

Add signable package persistence, envelope/session handling, webhook support, and lender/admin signing UI for ENG-288.

Also align the Documenso API env fallback with the local env files and fix the payments obligation monitoring tests flagged during the spec audit.

eng-289

review

Summary by CodeRabbit

Release Notes

  • New Features

    • Added document signing workflow with Documenso integration, enabling embedded signing sessions directly within deal pages.
    • Introduced signature status tracking and envelope management for signable documents.
    • Added broker deal detail pages with document viewing and signing capabilities.
    • Implemented archived signed documents section displaying final PDFs and completion certificates.
    • Expanded broker access control roles for deal access.
  • UI Improvements

    • Replaced placeholder signable document sections with actionable signing interfaces and status displays.
    • Added document refresh controls to sync signature status updates.
    • Enhanced deal detail view with recipient information and signing eligibility indicators.

@linear
Copy link
Copy Markdown

linear Bot commented Apr 20, 2026

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Connorbelez, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 91717af2-ea8e-4104-9087-7000157519ae

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch eng-289

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Owner Author

Connorbelez commented Apr 20, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@Connorbelez Connorbelez marked this pull request as ready for review April 20, 2026 02:58
Copilot AI review requested due to automatic review settings April 20, 2026 02:58
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Documenso-backed signing flow for signable deal-package documents, including normalized envelope/recipient persistence, embedded signing sessions, provider sync, and new broker-facing portal routes.

Changes:

  • Introduces signatureEnvelopes / signatureRecipients tables + signing-status extensions on generated documents, along with a Documenso provider adapter.
  • Updates deal package generation and read surfaces to create/sign/sync signable documents and expose recipient-aware portal/admin UI actions.
  • Adds broker portal routes and updates tests/spec artifacts for the ENG-288 rollout (plus a small obligations-cron monitoring adjustment).

Reviewed changes

Copilot reviewed 51 out of 52 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/test/lender/deal-detail-page.test.tsx Updates lender deal UI test fixtures/assertions for signable + archived signing surfaces.
src/test/convex/documents/dealPackages.test.ts Adds extensive signing-flow tests (Documenso mock, envelope/recipient persistence, sync, archival, idempotency).
src/test/broker/deal-detail-page.test.tsx Adds broker deal detail page test for archived artifacts rendering.
src/test/admin/deal-dedicated-details.test.tsx Updates admin deal details test for signable + archived artifacts UI and refresh action.
src/routes/broker/index.tsx Adds broker workspace landing route.
src/routes/broker.deals.tsx Adds broker deals route outlet.
src/routes/broker.deals.$dealId.tsx Adds broker deal detail route.
src/routeTree.gen.ts Regenerates route tree to include broker routes.
src/components/portal/deals/PortalDealDetailPage.tsx New shared portal deal detail UI with signable docs, recipient chips, embedded signing, refresh, and archive sections.
src/components/lender/deals/LenderDealDetailPage.tsx Replaces lender-specific page with shared PortalDealDetailPage wrapper.
src/components/broker/deals/BrokerDealDetailPage.tsx Adds broker wrapper around PortalDealDetailPage.
src/components/admin/shell/dedicated-detail-panels.tsx Replaces admin signable placeholder with envelope/recipient details, refresh action, and archived artifacts list.
specs/ENG-288/tasks.md Adds ENG-288 task tracking doc.
specs/ENG-288/summary.md Adds ENG-288 scope/constraints summary.
specs/ENG-288/status.md Adds ENG-288 execution status + blockers.
specs/ENG-288/execution-checklist.md Adds ENG-288 checklist and validation status.
specs/ENG-288/chunks/manifest.md Adds chunk manifest.
specs/ENG-288/chunks/chunk-04-tests-and-validation/tasks.md Adds chunk task list for tests/validation.
specs/ENG-288/chunks/chunk-04-tests-and-validation/status.md Adds chunk status/validation summary.
specs/ENG-288/chunks/chunk-04-tests-and-validation/context.md Adds chunk context doc.
specs/ENG-288/chunks/chunk-03-reads-and-ui/tasks.md Adds chunk tasks for reads/UI.
specs/ENG-288/chunks/chunk-03-reads-and-ui/status.md Adds chunk status/validation for reads/UI.
specs/ENG-288/chunks/chunk-03-reads-and-ui/context.md Adds chunk context for reads/UI.
specs/ENG-288/chunks/chunk-02-package-signing-flow/tasks.md Adds chunk tasks for signing flow.
specs/ENG-288/chunks/chunk-02-package-signing-flow/status.md Adds chunk status/validation for signing flow.
specs/ENG-288/chunks/chunk-02-package-signing-flow/context.md Adds chunk context for signing flow.
specs/ENG-288/chunks/chunk-01-schema-provider/tasks.md Adds chunk tasks for schema/provider.
specs/ENG-288/chunks/chunk-01-schema-provider/status.md Adds chunk status/validation for schema/provider.
specs/ENG-288/chunks/chunk-01-schema-provider/context.md Adds chunk context for schema/provider.
specs/ENG-288/audit.md Adds spec-audit output and requirement ledger.
convex/test/moduleMaps.ts Registers new signature modules in Convex test module map.
convex/schema.ts Adds signature envelope/recipient tables; extends generatedDocuments signing/archive fields; adds broker dealAccess roles.
convex/payments/obligations/monitoring.ts Adjusts same-day metric patching to keep max counts.
convex/payments/obligations/crons.ts Ensures monitoring row is recorded on no-backlog days (wave 0).
convex/payments/tests/crons.test.ts Updates cron tests to drain scheduled work consistently and align monitoring expectations.
convex/engine/effects/dealClosingEffects.ts Wires archive effect to real signable-archive action (no longer a stub).
convex/engine/effects/dealAccess.ts Extends deal access effect to grant broker roles and improves logging/idempotency.
convex/documents/signature/webhooks.ts Adds authenticated sync action to refresh envelope state from provider and persist normalized state.
convex/documents/signature/sessions.ts Adds authenticated embedded signing session issuance gated by canonical recipient match.
convex/documents/signature/provider.ts Adds provider interface + status mapping helpers.
convex/documents/signature/documenso.ts Implements Documenso adapter (config/env resolution, create/distribute, sync, artifact download).
convex/documents/dealPackages.ts Implements signable generation, envelope/recipient persistence, signing/archived surfaces, status summarization, and archiving action.
convex/documents/contracts.ts Adds validators/types for signature provider/status enums and new instance statuses.
convex/documentEngine/generation.ts Extends Documenso recipient config to include platformRole.
convex/deals/queries.ts Ensures portal deal detail query passes viewer context into document package surface for recipient eligibility.
convex/deals/mutations.ts Exports DealAccessRole and extends accepted roles with broker roles.
convex/deals/tests/access.test.ts Updates deal access tests for broker roles and new effect behavior.
convex/crm/detailContextQueries.ts Passes viewer context into package surface for admin/CRM deal detail context.
convex/crm/tests/detailContextQueries.test.ts Minor import ordering adjustment after viewer-user changes.
convex/auth/resourceChecks.ts Removes implicit broker-via-mortgage access; uses explicit dealAccess records.
convex/auth/tests/resourceChecks.test.ts Updates access tests to reflect broker requires explicit dealAccess.
convex/_generated/api.d.ts Regenerates API types to include new signature modules (and one added admin origination module).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread convex/documents/dealPackages.ts Outdated
Comment thread convex/documents/signature/webhooks.ts
Comment thread specs/ENG-288/summary.md Outdated
Comment thread specs/ENG-288/audit.md Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
convex/engine/effects/dealAccess.ts (1)

100-123: ⚠️ Potential issue | 🟠 Major

Don’t skip broker grants when lawyer data is absent.

The new broker grant path is unreachable when lawyerId is missing or lawyerType is unset, so broker_of_record / assigned_broker access can be skipped even though those grants do not depend on lawyer access.

Suggested fix
-		const lawyerId = deal.lawyerId;
-		if (!lawyerId) {
-			return;
-		}
-
-		if (!deal.lawyerType) {
-			console.warn(
-				`[createDealAccess] Deal ${args.entityId} has lawyerId but no lawyerType — skipping access grant`
-			);
-			return;
-		}
-
-		const grantsByUserId = new Map<string, DealAccessRole>([
-			[lawyerId, deal.lawyerType],
-		]);
+		const grantsByUserId = new Map<string, DealAccessRole>();
+		if (deal.lawyerId) {
+			if (deal.lawyerType) {
+				grantsByUserId.set(deal.lawyerId, deal.lawyerType);
+			} else {
+				console.warn(
+					`[createDealAccess] Deal ${args.entityId} has lawyerId but no lawyerType — skipping lawyer access grant`
+				);
+			}
+		}
 		const brokerGrants = await resolveBrokerDealAccessGrants(ctx, {
 			dealId: args.entityId,
 			mortgageId: deal.mortgageId,
 		});
 		for (const grant of brokerGrants) {
 			if (!grantsByUserId.has(grant.userId)) {
 				grantsByUserId.set(grant.userId, grant.role);
 			}
 		}
+
+		if (grantsByUserId.size === 0) {
+			return;
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/engine/effects/dealAccess.ts` around lines 100 - 123, The code
currently returns early when lawyerId or deal.lawyerType is missing which
prevents calling resolveBrokerDealAccessGrants and applying broker grants;
change createDealAccess logic so resolveBrokerDealAccessGrants is always called
and its results merged into grantsByUserId even if lawyerId or lawyerType is
absent: initialize grantsByUserId as an empty Map, if lawyerId and
deal.lawyerType exist then set(grant) for the lawyer, then await
resolveBrokerDealAccessGrants(ctx, { dealId: args.entityId, mortgageId:
deal.mortgageId }) and merge brokerGrants into grantsByUserId (using has before
set as existing code does), and remove the early returns that skip broker
processing.
🧹 Nitpick comments (8)
convex/deals/queries.ts (1)

214-243: Optional: dedupe viewer lookup when viewer is also the lender/seller.

When ctx.viewer.authId === deal.buyerId or === deal.sellerId, this Promise.all issues a redundant users index lookup. Harmless (query, not mutation), but a trivial optimization for the common "lender viewing their own deal" path:

♻️ Optional refactor
-		const [mortgage, property, lenderUser, sellerUser, viewerUser] =
-			await Promise.all([
+		const [mortgage, property, lenderUser, sellerUser] = await Promise.all([
 				ctx.db.get(deal.mortgageId),
 				ctx.db
 					.get(deal.mortgageId)
 					.then((mortgageRow) =>
 						mortgageRow ? ctx.db.get(mortgageRow.propertyId) : null
 					),
 				ctx.db
 					.query("users")
 					.withIndex("authId", (query) => query.eq("authId", deal.buyerId))
 					.unique(),
 				ctx.db
 					.query("users")
 					.withIndex("authId", (query) => query.eq("authId", deal.sellerId))
 					.unique(),
-				ctx.db
-					.query("users")
-					.withIndex("authId", (query) => query.eq("authId", ctx.viewer.authId))
-					.unique(),
 			]);
+		const viewerUser =
+			ctx.viewer.authId === deal.buyerId
+				? lenderUser
+				: ctx.viewer.authId === deal.sellerId
+					? sellerUser
+					: await ctx.db
+							.query("users")
+							.withIndex("authId", (q) => q.eq("authId", ctx.viewer.authId))
+							.unique();

Also note the pre-existing double ctx.db.get(deal.mortgageId) on lines 216/218 (mortgage is fetched twice); worth folding into the same cleanup if you touch this block again, but it's not introduced here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/deals/queries.ts` around lines 214 - 243, This block performs
redundant DB calls: ctx.db.get(deal.mortgageId) is invoked twice and the users
index query for viewerUser duplicates lenderUser/sellerUser when
ctx.viewer.authId equals deal.buyerId or deal.sellerId. Fix by fetching mortgage
once into the mortgage variable and derive property from that single mortgage
result, and avoid a separate users query for viewerUser when ctx.viewer.authId
matches deal.buyerId or deal.sellerId by reusing lenderUser or sellerUser
respectively (fall back to the indexed query only if no match). Ensure the rest
of the code (including the call to readDealDocumentPackageSurface using
viewerUser?._id and ctx.viewer.isFairLendAdmin) still receives the correct user
object.
src/components/admin/shell/dedicated-detail-panels.tsx (1)

1496-1514: Recipient chip React key can collide when multiple recipients share a platformRole.

If a signable envelope has, say, two borrower_primary/borrower_coborrower entries (or any two recipients with the same platformRole), the key ${document.instanceId}-${recipient.platformRole} collides and React will warn/misbehave on re-renders. Prefer a stable per-recipient identifier that the new signatureRecipients table already provides.

♻️ Proposed fix
-									key={`${document.instanceId}-${recipient.platformRole}`}
+									key={`${document.instanceId}-${recipient.providerRecipientId ?? recipient.userId ?? recipient.email ?? recipient.platformRole}`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/admin/shell/dedicated-detail-panels.tsx` around lines 1496 -
1514, The recipient chip key can collide because it uses
`${document.instanceId}-${recipient.platformRole}`; update the map in
document.signing.recipients to use a stable per-recipient identifier from the
new signatureRecipients table (e.g., recipient.id or
recipient.signatureRecipientId) instead of platformRole so keys are unique
across recipients (for example, replace the key expression with
`${document.instanceId}-${recipient.id}` or similar, falling back to a
deterministic composite only if that unique id is missing).
convex/documents/signature/webhooks.ts (1)

15-20: Filename vs. contents mismatch — no webhook handler here yet.

webhooks.ts currently only exports a user-triggered sync action (syncSignableDocumentEnvelope), not an HTTP webhook endpoint. If an actual Documenso webhook receiver is landing in a later chunk, that's fine; otherwise consider moving this into sessions.ts or a new sync.ts so the file name reflects its contents and future webhook code has a clean home.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/documents/signature/webhooks.ts` around lines 15 - 20, The file named
"webhooks" only contains the user-triggered authed action
syncSignableDocumentEnvelope (handler function syncSignableDocumentEnvelope)
rather than an HTTP webhook receiver; either move syncSignableDocumentEnvelope
into a more appropriate module (e.g., sessions or a new sync module) or rename
the file to reflect its purpose, and update exports/imports accordingly so the
symbol syncSignableDocumentEnvelope lives in a file whose name matches its
responsibility (or keep webhooks.ts only if you will add the actual webhook
handler later).
convex/documents/signature/documenso.ts (2)

140-144: DOCUMENSO_TIMEOUT_MS is parsed twice.

Minor, but worth extracting to a single parse to avoid re-running Number.parseInt and to make the > 0 guard clearer.

♻️ Proposed refactor
-    const timeoutMs =
-        process.env.DOCUMENSO_TIMEOUT_MS &&
-        Number.parseInt(process.env.DOCUMENSO_TIMEOUT_MS, 10) > 0
-            ? Number.parseInt(process.env.DOCUMENSO_TIMEOUT_MS, 10)
-            : DEFAULT_TIMEOUT_MS;
+    const parsedTimeout = process.env.DOCUMENSO_TIMEOUT_MS
+        ? Number.parseInt(process.env.DOCUMENSO_TIMEOUT_MS, 10)
+        : Number.NaN;
+    const timeoutMs =
+        Number.isFinite(parsedTimeout) && parsedTimeout > 0
+            ? parsedTimeout
+            : DEFAULT_TIMEOUT_MS;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/documents/signature/documenso.ts` around lines 140 - 144, The
timeoutMs computation parses process.env.DOCUMENSO_TIMEOUT_MS twice; parse it
once into a named variable (e.g., parsedTimeout =
Number.parseInt(process.env.DOCUMENSO_TIMEOUT_MS, 10)), then set timeoutMs =
parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS. Update the reference to
DOCUMENSO_TIMEOUT_MS and the timeoutMs assignment accordingly so the > 0 guard
is applied to the single parsed value.

263-275: Recipient matching may be ambiguous for multi-signer templates.

matchProviderRecipient keys on email + providerRole + signingOrder. If two recipients share the same email (e.g., the same lawyer appearing as both approver and signer, or a test fixture reusing an email across roles) and Documenso reorders recipients in its response, the wrong provider recipient id can be associated with the wrong platform role. Matching by externalId (if Documenso supports setting one per recipient on create) or by position in the request list would be more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/documents/signature/documenso.ts` around lines 263 - 275,
matchProviderRecipient currently identifies provider recipients by email +
providerRole + signingOrder which can be ambiguous when the same email appears
across roles or Documenso reorders its response; update matchProviderRecipient
(and any caller that builds SignatureProviderRecipientInput) to prefer matching
by a stable identifier: if DocumensoRecipientResponse.externalId (or a similar
per-recipient external id) exists, match input.externalId ===
recipient.externalId; otherwise, fall back to positional matching using the
original providerRecipients array index vs input.signingOrder (or an explicit
providerIndex field) instead of relying on email+role alone; adjust types
SignatureProviderRecipientInput and DocumensoRecipientResponse usage accordingly
so callers set externalId when creating recipients and the lookup uses it first.
src/test/convex/documents/dealPackages.test.ts (1)

93-198: LGTM on the mock Documenso fetch plumbing.

The mock covers envelope create/fetch/distribute/recipient, signed-PDF and completion-certificate download (both dedicated item and ?version=certificate fallback), with configurable failure toggles matching the provider's code paths in convex/documents/signature/documenso.ts. The ordering of branches correctly discriminates on pathname + version query param, and the 404 fallback for missing certificates mirrors tryDownloadOptionalArtifact's behavior.

One small note: re-calling installMockDocumensoFetch mid-test replaces the global stub, so earlier mocks' .mock.calls won't record subsequent calls. The archive-idempotency test (lines 1330–1518) handles this correctly by asserting on the latest mock's call counts, but it's worth a comment on the helper so future test authors don't trip on it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/convex/documents/dealPackages.test.ts` around lines 93 - 198, The
mock fetch helper (installMockDocumensoFetch / the block that creates fetchMock
and calls vi.stubGlobal("fetch", fetchMock)) replaces the global fetch when
invoked, which means calling it mid-test will swap out the stub and previous
mock call history is not preserved; add a concise comment above the helper
explaining this behavior and instructing test authors to assert call counts only
against the latest installed mock (or preserve references if they need
cumulative history).
src/components/portal/deals/PortalDealDetailPage.tsx (1)

632-647: onClose fires quiet sync even when dialog never actually opened.

Because the <dialog> is rendered for every active signable document, any programmatic close() (including ones not preceded by showModal() — e.g., if browser autocloses on route change) will trigger syncSignableDocumentEnvelope. That's usually harmless but can generate unnecessary provider traffic per signable row on unmount. Consider gating the sync on a "was opened" flag set inside launchEmbeddedSigning.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/portal/deals/PortalDealDetailPage.tsx` around lines 632 - 647,
The onClose handler for the dialog (identified by signingDialogId and using
signingFrameId) calls syncEnvelope for every rendered signable dialog even if it
was never opened; to fix, add a "wasOpened" flag (e.g., a React state or a Map
keyed by document.instanceId) that launchEmbeddedSigning sets to true when it
calls showModal/open, then modify the dialog onClose to only call syncEnvelope
and clear/reset the flag if wasOpened is true; ensure the flag is reset after
syncing and that the iframe src is still cleared unconditionally.
convex/schema.ts (1)

692-729: New signature tables look consistent with the provider/webhook flow.

Indexes align with the access patterns you use in convex/documents/signature/webhooks.ts and sessions.ts: by_deal, by_generated_document, by_provider_envelope for reconciliation, and by_envelope / by_envelope_provider_recipient / by_user_status for recipient lookups.

Two small things to consider:

  1. Convex doesn't enforce uniqueness, so (providerCode, providerEnvelopeId) and (envelopeId, providerRecipientId) are only unique by convention. Ensure the insert paths in documenso.ts/webhook sync do a withIndex(...).unique()-style check before inserting to avoid duplicate rows if a retry or webhook race fires twice.
  2. signatureRecipients.platformRole is a free-form v.string() — the rest of the codebase (e.g. templates seeded in tests) uses canonical tokens like borrower_primary, lawyer_primary, lender_primary. If you ever want to constrain this at the schema level, a validator mirroring the seeding vocabulary would prevent typo-driven access-control bugs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/schema.ts` around lines 692 - 729, The signature tables are fine but
you must (1) guard against duplicate inserts by checking the relevant index
uniqueness before writing: in your insert paths (references: functions in
documenso.ts and convex/documents/signature/webhooks.ts that create rows in
signatureEnvelopes and signatureRecipients) perform a lookup using the matching
index (e.g., index "by_provider_envelope" for signatureEnvelopes and
"by_envelope_provider_recipient" for signatureRecipients) and enforce uniqueness
(the Convex pattern: query the index and only insert if no existing row or
fail/update) to avoid retry/webhook races; and (2) optionally constrain
signatureRecipients.platformRole by replacing v.string() with a validator
listing the canonical tokens used in seeds (e.g., an enum validator matching
borrower_primary, lawyer_primary, lender_primary) to prevent typos.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/documents/dealPackages.ts`:
- Around line 2242-2268: The external provider.createEnvelope call is
non-idempotent and can create duplicate envelopes if the DB write fails; update
the flow to use a stable idempotency key (generatedDocumentId) and check for an
existing provider envelope before creating a new one: first query for any
existing providerEnvelopeId tied to generatedDocumentId (or call the provider
API to find an envelope by metadata) and if found reuse it; if not found, call
provider.createEnvelope passing generatedDocumentId as an idempotency/metadata
field so the provider can dedupe, then persist the providerEnvelopeId/lastError
by calling
internal.documents.dealPackages.createSignatureEnvelopeWithRecipientsInternal
(ensure that createSignatureEnvelopeWithRecipientsInternal records
providerEnvelopeId even when the provider reports an existing envelope); also
handle provider errors by storing lastError and treating subsequent retries
idempotently by re-checking the existing envelope via the same key.
- Around line 1487-1518: The loop over instances building targets throws for old
failed retry rows that were archived by archiveRetryInstanceIfNeeded; update the
loop in the block that creates targets (variables: instances, instance, targets)
to skip any instance that has been archived by the retry path by adding a guard
like if (instance.archived === true || instance.archivedAt) continue; before
validating generatedDocumentId and querying signatureEnvelopes (the
ctx.db.query("signatureEnvelopes")...unique() call), so archived incomplete
attempts are ignored and do not block archiving the successful package.
- Line 1178: The exported function patchGeneratedDocumentSigningStateInternal
(and the other newly exported internals mentioned) currently use raw
internalMutation/internalQuery/internalAction; replace those with the project’s
fluent-convex builder pattern and make visibility explicit by ending the
exported builder chain with .internal() (e.g., convert internalMutation(...) to
the repository’s fluent builder call and append .internal()); apply the same
change to all other new exported internals so each export uses the fluent
builder and an explicit .internal() visibility marker.
- Around line 2195-2197: The code currently returns early when
sourceBlueprintSnapshot.templateId is falsy, silently skipping creation;
instead, record a failed signable instance so finalization sees the missing
document. Replace the early return in the block referencing
sourceBlueprintSnapshot.templateId with logic that creates or appends a failure
record (e.g., push a signable instance with status 'failed', include blueprint
id/metadata and an error message like "missing templateId") or call the existing
failure helper (e.g., markSignableAsFailed/createFailedSignableInstance) so the
rest of the pipeline and package finalization will report the malformed
private_templated_signable rather than treating it as present.
- Around line 963-977: The failed-status classification (failedRows) includes
"signature_draft", "signature_declined", and "signature_voided" but the retry
eligibility filter only checks "generation_failed" and
"signature_pending_recipient_resolution", causing declined/voided/draft packages
to never be retried; update the retry filter to include the same failed statuses
as failedRows (add "signature_draft", "signature_declined", and
"signature_voided") so retry logic and failedRows are in sync, and apply the
same change to the analogous retry filter referenced around the other occurrence
(the block at 1852-1857).
- Around line 1194-1203: The patch call to ctx.db.patch on generatedDocumentId
is currently assigning optional args (completionCertificateStorageId,
documensoEnvelopeId, finalPdfStorageId, signingCompletedAt, signingStatus,
updatedAt) directly, which can unset existing fields when args are undefined;
change the patch to build the patch object only with keys whose corresponding
values are !== undefined (e.g., conditionally add
completionCertificateStorageId, finalPdfStorageId, signingCompletedAt,
signingStatus, documensoEnvelopeId fallback logic) before calling ctx.db.patch
for generatedDocumentId, and apply the same conditional-patch construction
pattern to the recipient patch (the recipient patch call) and the second
generatedDocument patch (the other ctx.db.patch for generatedDocument) so
optional timestamps like openedAt, signedAt, declinedAt are only written when
provided.

In `@convex/documents/signature/documenso.ts`:
- Around line 134-144: The current fallback sets appBaseUrl to
apiBaseUrl.replace(DOCUMENSO_API_SUFFIX_RE, "") which can leave API path
segments intact; change the logic in the appBaseUrl computation so when
DOCUMENSO_APP_BASE_URL is unset you defensively strip any "/api" path instead of
relying solely on DOCUMENSO_API_SUFFIX_RE (e.g., run a secondary regex to remove
"/api" and anything after it), and if that secondary strip yields a string that
still contains "/api" (or if apiBaseUrl clearly doesn't match expected API host
form) throw or log an explicit error rather than using the unsafe value; adjust
normalizeSigningUrl usage accordingly so it always receives a clean user-facing
base URL and reference variables/functions appBaseUrl, apiBaseUrl,
DOCUMENSO_API_SUFFIX_RE, and normalizeSigningUrl when making the change.
- Around line 419-474: The createAndOptionallyDistributeEnvelope flow currently
catches any /envelope/distribute error and returns status: "draft", which maps
to "signature_draft" and is not picked up by buildPackageWorkItems for retries;
change createAndOptionallyDistributeEnvelope to set a distinct retryable state
(e.g., "needs_distribution" or "signature_pending_distribution") and preserve
the created envelopeId and distributionError so subsequent retries call
/envelope/distribute rather than creating a new envelope; update
buildPackageWorkItems to include that new status in its retryable set and ensure
retryPackageGeneration (and createSignableGeneratedInstance) does not create a
fresh envelope when an existing envelopeId is present; add a unit/integration
test exercising failDistribute: true that asserts envelopeId is preserved and
that a later retry triggers /envelope/distribute instead of a new
/envelope/create.

In `@convex/engine/effects/dealAccess.ts`:
- Around line 125-138: The log in createDealAccess currently records user
identifiers via grantedRecords built from grantsByUserId and grantDealAccess;
change it to avoid emitting userId by recording only role and a non-identifying
count or accessId (e.g., store `${role}:${accessId}` or `${role}`) and update
the console.info call to log roles and totals (e.g., "Granted roles: ..., count:
N for deal=...") so no email/auth IDs are included; keep grantDealAccess usage
and grantedRecords variable but remove or replace userId when composing log
entries.

In `@specs/ENG-288/audit.md`:
- Line 37: The spec line claiming "OUT_OF_SCOPE" for final signed artifact
archival is inconsistent with the code wiring (archiveSignedDocuments ->
archiveCompletedSignableDocumentsInternal) and UI tests asserting archived final
PDF/certificate links; update the requirement text to reflect that archival of
signed artifacts is now in-scope (or alternatively remove/undo the wiring), and
explicitly state what remains for Phase 9: consuming downloadCompletedArtifacts
and persisting final artifacts into platform storage; reference the functions
archiveSignedDocuments and archiveCompletedSignableDocumentsInternal and the
consumer downloadCompletedArtifacts when making the clarification.

In `@src/components/portal/deals/PortalDealDetailPage.tsx`:
- Around line 249-274: The launchEmbeddedSigning function currently sets
frame.src and opens dialog only when DOM lookups succeed, which can silently
fail; change it to detect missing nodes and surface a toast error when either
the iframe (signingFrameId) or dialog (signingDialogId) is null, avoid opening a
dialog if the iframe is missing or avoid setting frame.src if dialog is missing,
and ensure you clear or don't mutate DOM state on failure; additionally replace
the document.getElementById lookups with React refs keyed by instance (useRef
per instance or a ref map) so the iframe/dialog references are deterministic
across re-renders (update launchEmbeddedSigning and any JSX that renders the
iframe/dialog to accept/use those refs).
- Around line 276-288: The header badges in PortalDealDetailPage render raw enum
strings; update the Badge label sources to pass detail.deal.status,
detail.documentPackage?.status, and detail.mortgage.status through the existing
formatEnumLabel helper (e.g., Badge children =>
formatEnumLabel(detail.deal.status),
formatEnumLabel(detail.documentPackage?.status ?? "No package yet") or only
format when a status exists, and formatEnumLabel(detail.mortgage.status)) so
snake_case values like "partial_failure" and "signature_sent" display as
human-friendly labels consistent with the other badges on the page.

In `@src/routes/broker.deals`.$dealId.tsx:
- Around line 4-11: Add a beforeLoad auth gate to the createFileRoute call for
the BrokerDealDetailRoute: implement a beforeLoad that reads the current
session/user (same mechanism used in src/routes/broker/index.tsx), verifies the
user is authenticated and has the broker role, and if not throws redirect({ to:
'/login', search: { redirect: location.href } }); update the Route definition
(the createFileRoute call that currently exports Route) to include this
beforeLoad so the page never renders unauthenticated content; leave
BrokerDealDetailRoute and Route.useParams unchanged and ensure you import/throw
redirect exactly as specified.

In `@src/routes/broker/index.tsx`:
- Around line 9-11: The Route currently created with
createFileRoute("/broker/")({ component: BrokerWorkspaceIndexRoute }) lacks a
beforeLoad auth gate; add a beforeLoad function to this route that checks
authentication and, if unauthenticated, throws redirect({ to: '/login', search:
{ redirect: location.href } }); also ensure you perform broker-role gating at
the island/component boundary (BrokerWorkspaceIndexRoute or its child island) so
only users with the broker role can view broker content. Use the Route and
BrokerWorkspaceIndexRoute symbols to locate where to add the beforeLoad and
where to enforce role checks.

---

Outside diff comments:
In `@convex/engine/effects/dealAccess.ts`:
- Around line 100-123: The code currently returns early when lawyerId or
deal.lawyerType is missing which prevents calling resolveBrokerDealAccessGrants
and applying broker grants; change createDealAccess logic so
resolveBrokerDealAccessGrants is always called and its results merged into
grantsByUserId even if lawyerId or lawyerType is absent: initialize
grantsByUserId as an empty Map, if lawyerId and deal.lawyerType exist then
set(grant) for the lawyer, then await resolveBrokerDealAccessGrants(ctx, {
dealId: args.entityId, mortgageId: deal.mortgageId }) and merge brokerGrants
into grantsByUserId (using has before set as existing code does), and remove the
early returns that skip broker processing.

---

Nitpick comments:
In `@convex/deals/queries.ts`:
- Around line 214-243: This block performs redundant DB calls:
ctx.db.get(deal.mortgageId) is invoked twice and the users index query for
viewerUser duplicates lenderUser/sellerUser when ctx.viewer.authId equals
deal.buyerId or deal.sellerId. Fix by fetching mortgage once into the mortgage
variable and derive property from that single mortgage result, and avoid a
separate users query for viewerUser when ctx.viewer.authId matches deal.buyerId
or deal.sellerId by reusing lenderUser or sellerUser respectively (fall back to
the indexed query only if no match). Ensure the rest of the code (including the
call to readDealDocumentPackageSurface using viewerUser?._id and
ctx.viewer.isFairLendAdmin) still receives the correct user object.

In `@convex/documents/signature/documenso.ts`:
- Around line 140-144: The timeoutMs computation parses
process.env.DOCUMENSO_TIMEOUT_MS twice; parse it once into a named variable
(e.g., parsedTimeout = Number.parseInt(process.env.DOCUMENSO_TIMEOUT_MS, 10)),
then set timeoutMs = parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS.
Update the reference to DOCUMENSO_TIMEOUT_MS and the timeoutMs assignment
accordingly so the > 0 guard is applied to the single parsed value.
- Around line 263-275: matchProviderRecipient currently identifies provider
recipients by email + providerRole + signingOrder which can be ambiguous when
the same email appears across roles or Documenso reorders its response; update
matchProviderRecipient (and any caller that builds
SignatureProviderRecipientInput) to prefer matching by a stable identifier: if
DocumensoRecipientResponse.externalId (or a similar per-recipient external id)
exists, match input.externalId === recipient.externalId; otherwise, fall back to
positional matching using the original providerRecipients array index vs
input.signingOrder (or an explicit providerIndex field) instead of relying on
email+role alone; adjust types SignatureProviderRecipientInput and
DocumensoRecipientResponse usage accordingly so callers set externalId when
creating recipients and the lookup uses it first.

In `@convex/documents/signature/webhooks.ts`:
- Around line 15-20: The file named "webhooks" only contains the user-triggered
authed action syncSignableDocumentEnvelope (handler function
syncSignableDocumentEnvelope) rather than an HTTP webhook receiver; either move
syncSignableDocumentEnvelope into a more appropriate module (e.g., sessions or a
new sync module) or rename the file to reflect its purpose, and update
exports/imports accordingly so the symbol syncSignableDocumentEnvelope lives in
a file whose name matches its responsibility (or keep webhooks.ts only if you
will add the actual webhook handler later).

In `@convex/schema.ts`:
- Around line 692-729: The signature tables are fine but you must (1) guard
against duplicate inserts by checking the relevant index uniqueness before
writing: in your insert paths (references: functions in documenso.ts and
convex/documents/signature/webhooks.ts that create rows in signatureEnvelopes
and signatureRecipients) perform a lookup using the matching index (e.g., index
"by_provider_envelope" for signatureEnvelopes and
"by_envelope_provider_recipient" for signatureRecipients) and enforce uniqueness
(the Convex pattern: query the index and only insert if no existing row or
fail/update) to avoid retry/webhook races; and (2) optionally constrain
signatureRecipients.platformRole by replacing v.string() with a validator
listing the canonical tokens used in seeds (e.g., an enum validator matching
borrower_primary, lawyer_primary, lender_primary) to prevent typos.

In `@src/components/admin/shell/dedicated-detail-panels.tsx`:
- Around line 1496-1514: The recipient chip key can collide because it uses
`${document.instanceId}-${recipient.platformRole}`; update the map in
document.signing.recipients to use a stable per-recipient identifier from the
new signatureRecipients table (e.g., recipient.id or
recipient.signatureRecipientId) instead of platformRole so keys are unique
across recipients (for example, replace the key expression with
`${document.instanceId}-${recipient.id}` or similar, falling back to a
deterministic composite only if that unique id is missing).

In `@src/components/portal/deals/PortalDealDetailPage.tsx`:
- Around line 632-647: The onClose handler for the dialog (identified by
signingDialogId and using signingFrameId) calls syncEnvelope for every rendered
signable dialog even if it was never opened; to fix, add a "wasOpened" flag
(e.g., a React state or a Map keyed by document.instanceId) that
launchEmbeddedSigning sets to true when it calls showModal/open, then modify the
dialog onClose to only call syncEnvelope and clear/reset the flag if wasOpened
is true; ensure the flag is reset after syncing and that the iframe src is still
cleared unconditionally.

In `@src/test/convex/documents/dealPackages.test.ts`:
- Around line 93-198: The mock fetch helper (installMockDocumensoFetch / the
block that creates fetchMock and calls vi.stubGlobal("fetch", fetchMock))
replaces the global fetch when invoked, which means calling it mid-test will
swap out the stub and previous mock call history is not preserved; add a concise
comment above the helper explaining this behavior and instructing test authors
to assert call counts only against the latest installed mock (or preserve
references if they need cumulative history).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 407a70df-2602-4fc8-b2c2-930a43502d07

📥 Commits

Reviewing files that changed from the base of the PR and between 28507c4 and f1871d1.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (51)
  • convex/auth/__tests__/resourceChecks.test.ts
  • convex/auth/resourceChecks.ts
  • convex/crm/__tests__/detailContextQueries.test.ts
  • convex/crm/detailContextQueries.ts
  • convex/deals/__tests__/access.test.ts
  • convex/deals/mutations.ts
  • convex/deals/queries.ts
  • convex/documentEngine/generation.ts
  • convex/documents/contracts.ts
  • convex/documents/dealPackages.ts
  • convex/documents/signature/documenso.ts
  • convex/documents/signature/provider.ts
  • convex/documents/signature/sessions.ts
  • convex/documents/signature/webhooks.ts
  • convex/engine/effects/dealAccess.ts
  • convex/engine/effects/dealClosingEffects.ts
  • convex/payments/__tests__/crons.test.ts
  • convex/payments/obligations/crons.ts
  • convex/payments/obligations/monitoring.ts
  • convex/schema.ts
  • convex/test/moduleMaps.ts
  • specs/ENG-288/audit.md
  • specs/ENG-288/chunks/chunk-01-schema-provider/context.md
  • specs/ENG-288/chunks/chunk-01-schema-provider/status.md
  • specs/ENG-288/chunks/chunk-01-schema-provider/tasks.md
  • specs/ENG-288/chunks/chunk-02-package-signing-flow/context.md
  • specs/ENG-288/chunks/chunk-02-package-signing-flow/status.md
  • specs/ENG-288/chunks/chunk-02-package-signing-flow/tasks.md
  • specs/ENG-288/chunks/chunk-03-reads-and-ui/context.md
  • specs/ENG-288/chunks/chunk-03-reads-and-ui/status.md
  • specs/ENG-288/chunks/chunk-03-reads-and-ui/tasks.md
  • specs/ENG-288/chunks/chunk-04-tests-and-validation/context.md
  • specs/ENG-288/chunks/chunk-04-tests-and-validation/status.md
  • specs/ENG-288/chunks/chunk-04-tests-and-validation/tasks.md
  • specs/ENG-288/chunks/manifest.md
  • specs/ENG-288/execution-checklist.md
  • specs/ENG-288/status.md
  • specs/ENG-288/summary.md
  • specs/ENG-288/tasks.md
  • src/components/admin/shell/dedicated-detail-panels.tsx
  • src/components/broker/deals/BrokerDealDetailPage.tsx
  • src/components/lender/deals/LenderDealDetailPage.tsx
  • src/components/portal/deals/PortalDealDetailPage.tsx
  • src/routeTree.gen.ts
  • src/routes/broker.deals.$dealId.tsx
  • src/routes/broker.deals.tsx
  • src/routes/broker/index.tsx
  • src/test/admin/deal-dedicated-details.test.tsx
  • src/test/broker/deal-detail-page.test.tsx
  • src/test/convex/documents/dealPackages.test.ts
  • src/test/lender/deal-detail-page.test.tsx

Comment thread convex/documents/dealPackages.ts
Comment thread convex/documents/dealPackages.ts Outdated
Comment thread convex/documents/dealPackages.ts Outdated
Comment thread convex/documents/dealPackages.ts
Comment thread convex/documents/dealPackages.ts
Comment thread specs/ENG-288/audit.md Outdated
Comment thread src/components/portal/deals/PortalDealDetailPage.tsx Outdated
Comment thread src/components/portal/deals/PortalDealDetailPage.tsx
Comment thread src/routes/broker.deals.$dealId.tsx
Comment thread src/routes/broker/index.tsx
Add signable package persistence, envelope/session handling, webhook support, and lender/admin signing UI for ENG-288.

Also align the Documenso API env fallback with the local env files and fix the payments obligation monitoring tests flagged during the spec audit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants