User answered 11 blocking + clarifying questions across the audit doc. Decisions inlined as a summary block in the audit doc prelude so any session reading the doc sees the answers up front before drilling into individual findings. Highlights: - Documenso comprehensive audit ships as 5 discrete sub-PRs. - Pre-flight validation hard-blocks Submit; no override path. - `/documents/new` wizard refactor: delete upload branch, drop inapp pathway, per-port doc-type template defaults, surface flow 3 from dropdown, drop the route entirely. - Automate Signing: pick-up on mid-flow enable; broadcast to all recipients; single combined mode; manual override stays visible. - Webhook URL auto-PATCH env-flag-gated. - documenso_signing_order becomes a tri-state setting. - OverviewTab inheritance writes to interest, prompt to also update yacht record. - Public-map flag inheritance applies across every map-flip dialog. - Cancel/Delete affordance audit sweeps EVERY remove route. - Orphan-scan script deferred; dev DB nuke acceptable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 KiB
Active UAT — running findings
THIS IS THE CURRENTLY ACTIVE AUDIT DOC. All new UAT findings land here regardless of which session captures them. Persists across sessions until the user explicitly says "wrap this round up and start a fresh one" — at which point archive this file with a date stamp (
YYYY-MM-DD-uat.md) and start a newactive-uat.md.Started 2026-05-26 after the drain commit
e9509dccleared the prioralpha-uat-master.mdlong tail. This file is the home for findings surfaced as the user walks through the running app. Append every item as a discrete entry — even premature / aspirational ones — so nothing gets dropped.Methodology: user drives the live CRM at
http://localhost:3000, surfaces issues in chat (with screenshot + React-grab anchor when applicable). Each finding lands here in the matching bucket with file:line evidence and a status tag.Status legend:
OPEN— captured, not startedIN PROGRESS— currently being worked on this sessionSHIPPED in <hash>— committed; commit message has detailQUEUED— not for this session; deliberately deferredBLOCKED— waiting on user input / external repo / clarificationSeverity (for bugs only):
critical | high | medium | low.
Locked decisions — 2026-05-26 round. User answered 11 blocking / clarifying questions. Inlined here for cross-finding reference; individual findings still carry their own context.
- Documenso comprehensive audit: ship as 5 discrete sub-PRs — (1) persist
documensoIdimmediately after create, (2) pre-flight validation, (3) state-machine refactor withrollbackTo()helper, (4) recipient ↔ Documenso identity reconciliation, (5) end-to-end test coverage + audit-log richness.- Pre-flight validation for upload-for-signing: hard-blocks Submit when any recipient has a missing email or any placed field's
recipientIndexdoesn't resolve. No override path./documents/newwizard refactor: (a) delete the upload branch, (b) drop theinapptemplate pathway, (c) per-port doc-type template defaults (documenso_eoi_template_id/documenso_reservation_template_id/documenso_contract_template_id) with admin-only override, (d) surface flow 3 (mark externally signed) from the dropdown menu, (e) drop/documents/newas a route — replace with<GenerateDocumentDialog>opened from the dropdown.- Automate Signing button: mid-flow enable picks up from next-in-order signer; completion broadcast goes to ALL recipients (signers + approvers + CCs); single combined mode (no partial-automate); manual override buttons stay visible with "Auto-firing soon" tooltip during automation.
- Webhook URL auto-PATCH on tunnel restart: env-flag-gated via
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1. Prod can't be accidentally rotated by a stale dev script.- Admin Webhook Health page: explicit "Test now" button for ports with no webhooks received. No auto-fire on first page load.
- Per-port
documenso_signing_ordersetting: tri-state — SEQUENTIAL / PARALLEL / Use template default (null/empty state). Replaces the binary toggle.- OverviewTab inheritance editing: writes to the interest's
desired_*column (override pattern). Save toast surfaces a follow-up "Update yacht record too?" CTA so the rep can promote the change up if the yacht itself is wrong.- Public-map flag inheritance: applies across every dialog with a map-flip affordance — EOI generate, External EOI upload, Reservation generate + upload, Contract generate + upload. Default: ON when ANY in-bundle berth has
is_specific_interest=true, OFF otherwise.- Cancel/Delete affordance audit: sweep EVERY remove route (per-row EOI tab kebab, EoiCancelDialog, docs hub kebab, document detail Cancel + Delete, contract/reservation tab equivalents, NewDocumentMenu if any). Each one must run the same
cancelDocument/deleteDocumentservice flow with permission check + Documenso void whendocumensoIdset + status transition + onSuccess query invalidation + toast on error.- Orphan-scan admin script: deferred / out of scope. Dev DB nuke acceptable for UAT-session debris.
Bucket 1 — Quick fixes (<15 min)
Dialog primitive default too narrow → bump platform-wide
OPEN— src/components/ui/dialog.tsx (DialogContent base default).- Symptom: Dialog primitive's default is
sm:max-w-lg(512px), which is far too narrow for most content (forms, file previews, signing details). Even the earlier per-dialoglg:max-w-4xlbump only fixed the dialogs I explicitly migrated; everything still using the default — including FilePreviewDialog (which overrides tomax-w-4xlbut PDFs are unreadable at that width) — stays cramped on desktop. - Fix: bump the Dialog primitive base to
sm:max-w-2xl lg:max-w-4xlso every Dialog gets a sane wide-screen default. Per-dialog overrides ride on top for cases that need wider (PDF preview) or narrower (confirm dialogs).
FilePreviewDialog cramped for PDFs
IN PROGRESS— src/components/files/file-preview-dialog.tsx:109.- Symptom: opening a PDF lands in a
max-w-4xl(896px) container on a 1920px+ desktop; PDF renders in a thin column with massive empty bands on both sides. Screenshot 2026-05-26. - Fix applied: bumped DialogContent to
w-[min(95vw,1400px)] sm:max-w-none lg:max-w-none h-[85vh]so PDFs get viewport-sized rendering capped at 1400px. Reference for "correct" width is the documents-tab preview which the user confirmed reads correctly.
CreateDocumentWizard — doc-type labels lowercased
SHIPPED locally (not yet committed)— src/components/documents/create-document-wizard.tsx + src/lib/constants.ts.- Symptom: doc-type dropdown renders
eoi,nda,reservation agreement,other— lowercase, looks unfinished. Naive.replace(/_/g, ' ')doesn't capitalize. - Fix applied: added
DOCUMENT_TYPE_LABELSRecord alongside the enum (EOI,Contract,NDA,Reservation Agreement,Other). Wizard reads from the map.
CreateDocumentWizard — "Other" hint added
SHIPPED locally (not yet committed)— src/components/documents/create-document-wizard.tsx.- Decision: kept schema unchanged. Added an inline hint under the type selector when
otheris selected: "Use the Title below to describe the document — that's how it'll appear everywhere it's referenced."
FlatFolderListing — needs padding above the list
SHIPPED locally (not yet committed)— src/components/documents/documents-hub.tsx FlatFolderListing.- Symptom: the flat list sat flush against the subfolders UI above it — no vertical breathing room.
- Fix applied: wrapped FlatFolderListing's returned tree in
<div className="space-y-4">so all three sub-sections (search/chip row, subfolders grid, documents list) get consistent vertical spacing.
FlatFolderListing — root folder doesn't show uploaded files
SHIPPED locally (not yet committed)— src/components/documents/documents-hub.tsx FlatFolderListing + src/lib/services/files.ts (listFiles) + src/lib/validators/files.ts (already had folderId; service was ignoring it).- Root cause: documents table (signature workflows) and files table (raw uploads) are separate; FlatFolderListing queried documents only.
- Fix applied: went with option B (parallel files query + client-side merge).
listFilesnow honours thefolderIdfilter that was already accepted by the validator. FlatFolderListing runs a siblinguseQueryagainst/api/v1/files?folderId=Xand merges both sources into a unifiedHubRowlist sorted bycreatedAt desc. NewrenderFileRowrenders files with an "Uploaded file" type pill + "Stored" status pill, links the filename to the download URL. Existing FolderDropZone invalidation (['files']prefix) already covers the new query, so drag-drop AND New-document-menu uploads both refresh the list without a page reload.
FlatFolderListing — chevron does nothing when no signers
SHIPPED locally (not yet committed)— src/components/documents/documents-hub.tsx:359+.- React-grab anchor:
<svg class="lucide lucide-chevron-right h-4 w-4" />in FlatFolderListing. - Symptom: every row renders a chevron button that's meant to expand signers detail. For docs with zero signers (manually uploaded, or signature docs that were cancelled/voided before recipients were added), clicking does nothing — the button toggles state but no signers panel exists to render.
- Fix applied: chevron button only renders when
totalSigners > 0. Layout column kept (transparent placeholder span) so grid alignment doesn't jump.
Interest drawer — inline client create
SHIPPED locally (not yet committed)— src/components/interests/interest-form.tsx + src/components/clients/client-form.tsx.- Symptom: rep starts a new interest, realises the client isn't on file, has to close the drawer + navigate to Clients + create + come back. Yacht create was already inline ("Add new" button next to YachtPicker); client create wasn't.
- Fix applied: ClientForm gains an
onCreated(id)callback; the create-branch mutation now returns{ id }. InterestForm renders an "Add new" Button next to the Client label (create-mode only — hidden on edit), opens the ClientForm Sheet, and auto-selects the newly-created client into the interest draft on success.
InterestForm reset path dropped source='manual'
SHIPPED locally (not yet committed)— src/components/interests/interest-form.tsx.- Symptom:
defaultValuessetsource: 'manual', but the!interest && openreset path didn't include it. Reopening the drawer for a new interest landed on an unselected source dropdown. - Fix applied: reset() block now includes
source: 'manual'alongside the other create-mode defaults.
UploadForSigningDialog — recipients show only one name, no email differentiator + role
SHIPPED locally (not yet committed)- Files touched: src/components/documents/upload-for-signing-dialog.tsx (RECIPIENT_ROLE_META + RecipientRoleBadge helpers + placement-step sidebar render + FieldSidePanel dropdown).
- React-grab anchor:
<div class="space-y-1" />inFieldPlacementStepinDialogBody. - Symptom: placement-step's recipients sidebar (and the FieldSidePanel's "Assign this field to" dropdown) displayed only the recipient's NAME — no email, no role. UAT screenshot showed 4 recipients all literally named "matt 1, matt 2, matt 3, matt 4" with no way to distinguish them; reps editing real docs with duplicate names (e.g. multiple family members on a yacht purchase) hit the same problem. Worse: the failure of the "missing recipientId" error (separate finding below) is silently caused by which-email-maps-to-which-recipient confusion that the rep can't see.
- Root cause: the recipient rows in both surfaces were rendered as
r.name || r.email || #signingOrder— falling back to email ONLY when name was blank. With non-blank names, email never showed. Role was tracked in state ('SIGNER' | 'APPROVER' | 'CC'on the Recipient interface) but never rendered. - Fix applied:
- New
RECIPIENT_ROLE_METAconstant maps each role to display label + tint (Signer blue, Approver amber, CC slate). NewRecipientRoleBadgecomponent renders the pill. - Sidebar list rewritten as a two-line layout: line 1 is name + role badge, line 2 is the email (or "no email set" placeholder so the row doesn't shift). Email is also surfaced via
titlefor hover-truncation tolerance. - FieldSidePanel dropdown SelectItem rebuilt as a stacked layout — name + role badge on top, email muted below — so reps differentiating duplicate-named recipients can pick the right one without expanding the dropdown.
- New
- Alternatives considered + rejected:
- Showing only email and dropping name — rejected because the cleaner display people want is "Matthew Ciaccio · matt@gmail.com (Signer)", not pure email.
- Color-coded chip strip instead of a dropdown — rejected for the same density reason captured in the prior "Assign this field to" finding.
- Effort: ~30 min (helpers + two render-site rewrites + tsc).
- Cross-refs: pairs with the "Assign this field to" label fix (just above). Both ship the same UAT round.
- Acceptance criteria: placement-step sidebar shows {color-dot, name, role badge, email} per recipient; FieldSidePanel dropdown options show {#order, name, role badge, email} per option; duplicate-named recipients are visually distinguishable by email.
Documenso upload — silent partial-state when field placement fails
PARTIALLY SHIPPED (rollback gap fixed; comprehensive audit still queued)- Files touched (this fix): src/lib/services/custom-document-upload.service.ts (~line 400, placeFields try/catch). src/components/documents/upload-for-signing-dialog.tsx (recipient UI sibling fix shipped separately).
- Symptom: rep uploads a PDF, places fields, hits Send. Error toast surfaces:
Documenso response missing recipientId for matt.ciaccio@gmail.com - cannot place fields. Document appears in the CRM's signing UI AND in Documenso, recipients + roles are wired, but all placed fields are missing. The signing UI on the receiving end has no boxes to fill, which means a signer who receives the invite via email lands on a useless page. - Root cause: in
placeFieldsFromUpload, the placements were built viafields.map(f => { if (!recipientId) throw ConflictError(...) ...})BEFORE the surrounding try/catch. The synchronous throw frommap()bubbled past the catch-and-rollback block that wrapsplaceFields(), so when the recipient lookup missed:- Documenso envelope: already created + distributed (
sendDocsucceeded earlier in the flow). - Recipients: created with correct roles, signing URLs issued.
- Fields: never placed (the throw fired BEFORE the placeFields call).
- CRM document row: stuck in
'sent'status because the rollback only fired inside the try/catch that the throw skipped over. Result: the partial state the user described.
- Documenso envelope: already created + distributed (
- Fix applied (this session):
- The placements
map()is now INSIDE the same try/catch that wrapsplaceFields(). Any throw — sync or async — triggers the rollback (Document row → cancelled, Documenso envelope → voided). - Pre-throw
logger.error(...)captures diagnostic state: the missed email, every email the Documenso response DID return. Future "why didn't this match" investigations have something to grep instead of guesswork. - Comment block explaining the dedupe semantic (Documenso de-dupes by email at the envelope level, so duplicate emails across CRM recipient rows all map to the same Documenso recipientId — that's expected behaviour, not a bug).
- The placements
- Still open — comprehensive audit: user explicitly asked for a full pass on the upload-for-signing flow because "we need to do a comprehensive audit of the uploading to be sent through documenso to ensure all fields are wired up correctly and won't cause issues." The rollback gap is one example of multi-step orchestration that fails silently; the wider audit should cover:
- Pre-flight validation BEFORE envelope creation. Validate every recipient row has a usable email; validate every placed field's
recipientIndexresolves to a recipient that has a non-empty email; validate at least one field exists when documentType requires one. Failing pre-flight returns a 400 with field-level errors, no Documenso side effects. - Per-step rollback hardening. Currently rollback paths exist after
documensoCreate,documensoSend, andplaceFields, but they're independent try/catches. Refactor into a single sequenced state machine with an idempotentrollbackTo(step)helper so future inserts (e.g. metadata write between steps) inherit the rollback automatically. - Recipient → Documenso identity reconciliation. Today the code assumes Documenso's response will echo every input email. When dedupe happens (same email → one recipient), the code WORKS correctly (mapping just collapses), but if Documenso silently DROPS a recipient (e.g. invalid email), the lookup throws and we get the silent-partial-state again. Add explicit reconciliation: after
sendDoc, verify that every distinct email we sent IS present insentDoc.recipients; raise a specific error type if not. - Idempotency on retry. If the rep hits Send twice (network blip), do we double-create envelopes? Verify the document row's
documensoDocumentIdis checked before anotherdocumensoCreatecall. - End-to-end tests covering every failure path. vitest integration suite for: empty fields list, recipient with blank email, recipient with duplicate email, Documenso 4xx on create, Documenso 4xx on send, Documenso 4xx on place-fields, network timeout mid-flow.
- Audit-log every milestone. Currently we log on the final success / final failure. Per-step audit entries (
create_envelope,send_envelope,place_fields) would make post-mortem investigation tractable.
- Pre-flight validation BEFORE envelope creation. Validate every recipient row has a usable email; validate every placed field's
- Effort for comprehensive audit: ~6–10h. ~2h pre-flight validation, ~3h state-machine refactor + tests, ~1h reconciliation logic, ~1h idempotency, ~2h test coverage, ~1h audit-log richness. Bundle as one PR because the pieces interleave.
- Cross-refs:
- The
/documents/newwizard refactor (Bucket 3 — wizard refactor finding) touches the same end-to-end flow — bundle the two so the same audit doesn't re-investigate the upload-for-signing service twice. - This is the SECOND time a multi-step Documenso flow has had a rollback gap — the first was the EOI auto-cancel/replace flow (fixed earlier in
65ff596). Pattern: every multi-step orchestration that touches Documenso needs end-to-end rollback OR pre-flight validation. The audit doc's broader "activity feed comprehensive copy" finding mentioned a similar discipline gap; both should land before more multi-step features ship.
- The
- Open questions for the user:
- Are you okay with the comprehensive audit being one larger PR (~1-2 days focused), or should it ship as discrete sub-PRs (pre-flight + state-machine + tests)? Trade-off: single PR is faster but harder to review; sub-PRs are reviewable but you'd see intermediate states.
- Should the pre-flight validation block the dialog Submit button entirely, or surface an inline error and let the rep submit anyway (with "I know there's a missing email" override)? Default proposal: hard block — Documenso's API can't recover from missing emails, so submitting anyway is guaranteed-to-fail.
BerthRecommenderPanel — hide entirely when no desired dimensions set
SHIPPED locally (not yet committed)- Files touched: src/components/interests/interest-tabs.tsx (~line 1467 Overview inline render + ~line 1577 dedicated tab entry + ~line 1521 hasDesiredDims gate variable + ~line 711 OverviewTab inner gate).
- React-grab anchor:
<div class="flex flex-col s..." />inCardinBerthRecommenderPanel. - Symptom: the recommender card rendered even when the rep hadn't entered any desired dimensions on the interest — surfacing only the "Set desired dimensions to see recommendations." guidance subtitle. User flagged that the card AND the dedicated "Berth Recommendations" tab should both be hidden in that state so reps aren't distracted by an empty placeholder.
- Root cause: previous design intentionally kept the panel always-mounted with inline guidance ("plan §5.3 — always-mounted card driven by the interest's desired dimensions"). User-experience preference now flips that to hide-entirely.
- Fix applied:
- Computed
hasDesiredDims = toNum(interest.desiredLengthFt) !== nullonce near the top of the InterestTabs component, and once inside OverviewTab (because the Overview's inline render lives inside the child). - Overview tab's BerthRecommenderPanel mount wrapped in
{hasDesiredDims ? <Panel /> : null}— disappears entirely until length is captured. - Dedicated "Berth Recommendations" tab object spread conditionally into the tabs array (
...(hasDesiredDims ? [tabObject] : [])) so the tab strip's tab itself vanishes — not just the content. Rep doesn't get a dead-end tab.
- Computed
- Why gate on length only (not all three dimensions): length is the primary ranking input in the recommender's SQL; width / draft fall back to length when missing. Requiring all three would hide the panel for partial-data interests where the recommender still has signal.
- Alternatives considered + rejected:
- Show the panel but collapsed by default — rejected because reps still see the empty card; defeats the user's "hide entirely" ask.
- Keep the dedicated tab but show the empty-state inside — rejected for the same reason; the user wants the tab gone too.
- Effort: ~15 min.
- Cross-refs: related to the Bucket 3 wizard refactor / OverviewTab inheritance finding — both touch what gets shown to a rep on the Overview tab as a function of what data is present.
- Acceptance criteria: an interest with
desiredLengthFt = NULLshows no recommender card on Overview AND no "Berth Recommendations" tab in the strip. Setting desired length via the inline editor causes both to appear immediately (TanStack Query refetch).
Per-berth public-map flag — should inherit on subsequent surfaces
OPEN — needs user clarification on which surface specifically- React-grab anchor:
<label class="flex items-cent..." />inDismissableLayerinFocusScopeinPresence(i.e., inside a Radix Dialog or Sheet). - User's message (verbatim): "this should inherit from on the overview page if the berths on the interest record are marked as being changed/updated on the public map."
- Best-guess interpretation: the label-anchor lives inside a dialog (DismissableLayer / FocusScope wrap is Radix's modal portal). Most likely candidates given recent UAT focus:
- EOI generate dialog (
src/components/documents/eoi-generate-dialog.tsx) — when the rep generates an EOI from the dialog, a checkbox controls whether the in-bundle berths' public-map status flips to "Under Offer." That checkbox should default to ON when any of the linked berths already haveis_specific_interest=true, OR be defaulted based on those existing flags. - External EOI upload dialog — same logic, parallel checkbox.
- Reservation generate / external upload — same pattern at a later stage.
- Bulk berth-tagging surfaces — less likely given the recent flow.
- EOI generate dialog (
- Root cause hypothesis: these dialogs currently default their map-flip checkbox to a static value (probably
true), without reading the existing per-rowis_specific_interestflags on the interest'sinterest_berthsrows. So a rep who explicitly turned the flag OFF on the linked-berths list (because they didn't want the map to flip yet) gets the dialog overriding their choice. - Fix proposal (when target surface is confirmed):
- Query the interest's
interest_berthsrows when the dialog opens. Derive the default: if ANY in-bundle berth hasis_specific_interest=true, default the dialog's checkbox to true. Otherwise default false. - Better: surface a per-row indicator inside the dialog showing the current map flag state per berth, so the rep sees which berths will / won't flip and can override per-row.
- Wire submit to honour those per-row toggles instead of a single global checkbox.
- Query the interest's
- Effort: ~30 min for option 1 (single dialog), ~1.5h for option 2 (per-row UI) once the target dialog is identified.
- Open questions for the user:
- Which dialog were you looking at when you flagged this? Best to confirm before I touch any code — the label anchor doesn't uniquely identify it. Screenshot of the dialog would close the gap immediately.
- Default semantic: when ANY in-bundle berth has the flag on, should the dialog default the public-map flip to ON, or should it match the MAJORITY of berths' flags, or should it always be a deliberate per-dialog choice?
Documenso upload — title transfer (verification + concern)
VERIFIED WORKING (no fix needed); UX cue queued- Files inspected: src/lib/services/custom-document-upload.service.ts (line 388
documensoCreate(title, ...)). - User concern: "not sure if the name I gave the document transferred through to the documenso document (not sure if i gave it a name or left it default)."
- Verification: the upload-for-signing service passes the
titlefield through todocumensoCreate(title, pdfBase64, ...)at line 388. Documenso's create call accepts the title verbatim. Same pattern in the EOI generate flow (template-based) — title is sent via the template-generate API. - Why the user couldn't tell: the dialog's submission flow returns to the EOI tab + document list without surfacing the title that ended up on the Documenso side. If the rep left it default (no title input) the local CRM defaulted to something like "Dashboard report — 22_05_2026" (per screenshot evidence) — Documenso received exactly that string. Nothing was lost.
- Queued UX fix (small): after a successful send, show the title prominently in the success toast ("Sent for signing: 'Dashboard report — 22_05_2026' → Documenso") so the rep can immediately confirm what name landed on the receiving side. Bundle with the broader Documenso upload audit (above).
Documenso upload + delete — orphaned envelopes when CRM document row has no documensoId
OPEN (multiple linked bugs; root cause shared with the silent-partial-state finding above)- Files implicated:
- src/lib/services/custom-document-upload.service.ts:498 (
documensoIdis only written to the CRM row AFTERplaceFieldssucceeds). - src/lib/services/documents.service.ts:648 (
deleteDocument— best-effort void only runsif (existing.documensoId); skips silently when null). - src/lib/services/documents.service.ts:2220 (
cancelDocument— same gated void at line 2240). - src/lib/services/documents.service.ts:192 (
listDocumentsfilters outstatus='deleted'by default). - src/components/interests/interest-eoi-tab.tsx:121 (EOI tab query).
- src/lib/services/custom-document-upload.service.ts:498 (
- Symptom chain (UAT 2026-05-26):
- Rep uploads a custom doc via UploadForSigningDialog → field placement throws (the "missing recipientId" bug captured above). Before my session fix, the throw bypassed the rollback. So:
- Documenso side: envelope created, recipients distributed, no fields placed.
- CRM side: document row at
status='draft',documensoId=NULL(never written because line 498 is after the throw).
- Rep "removed the EOI" via the CRM UI — but the doc STILL displays as DRAFT in the EOI tab.
- Rep also confirms it wasn't deleted from Documenso side either.
- Rep uploads a custom doc via UploadForSigningDialog → field placement throws (the "missing recipientId" bug captured above). Before my session fix, the throw bypassed the rollback. So:
- Root cause (multi-part):
- A. CRM lost the link to Documenso. Because step 1 left
documensoId=NULLon the CRM row, bothdeleteDocumentandcancelDocumentskip the Documenso void call (if (existing.documensoId)short-circuits). The CRM has no way to find the envelope to void. Documenso is now hosting an orphaned envelope. - B. Whatever "remove" action the rep took didn't transition the status. The screenshot shows the doc still as DRAFT after the rep's remove attempt. If
cancelDocumenthad run, status would becancelled. IfdeleteDocumenthad run, the row would be filtered out of the EOI tab list (line 195 excludesstatus='deleted'). So the rep's action either errored silently OR triggered a route we haven't identified. - C. The earlier silent-partial-state bug is the seed. Without my session fix to the rollback, every failure of
placeFieldsleft a phantom draft + orphaned envelope. Reproduced reliably until the rollback fires correctly.
- A. CRM lost the link to Documenso. Because step 1 left
- Hypothesis ladder for the "remove" action that didn't take:
- The rep clicked a cancel/delete affordance but the request 4xx'd (permission denied, validation error) and the toast was missed. The list query never re-ran because the mutation didn't onSuccess-invalidate.
- The rep deleted from Documenso UI directly (not the CRM), and confused that with a CRM-side remove. The CRM still has the row.
- There IS a CRM-side button that hit a route we missed — e.g. a soft-archive that doesn't change status.
- Fix proposal (multi-layer):
- Persist
documensoIdIMMEDIATELY afterdocumensoCreate, not at the end. Move theUPDATE documents SET documensoId=...call to right afterdocumensoCreatesucceeds (line ~388). Subsequent failures will still rollback the status, but the CRM retains the Documenso reference so void calls work. Acceptable risk: the row briefly has a documensoId but status='draft'; the rollback path resolves it. - Audit every CRM-side "remove EOI / cancel doc / delete doc" affordance. Each one should: (a) check the rep has permission, (b) call the right service (
cancelDocumentfor active flows,deleteDocumentfor drafts), (c) onSuccess-invalidate the relevant queries, (d) surface toast on error not just silently swallow. List candidates: EoiCancelDialog (line 25 of interest-eoi-tab), the EOI tab's per-row kebab actions (currently in interest-eoi-tab.tsx near the doc list render), the docs hub kebab actions, the document detail page's Cancel/Delete buttons. - Surface "this row has no Documenso link" in the UI. When a CRM doc has documensoId=NULL but status not in {draft (pre-send), deleted}, render a small warning chip ("Documenso link lost — cancel + recreate this doc") with a "Repair" CTA that voids the envelope IF the rep can supply a Documenso id, or marks the doc cancelled + lets them recreate.
- Reconciliation cron / repair script. Periodic (or admin-triggered) job that lists Documenso envelopes the CRM doesn't have a row for, surfaces them for review. Catches orphans across upgrades / past partial failures.
- Persist
- Effort:
- Fix #1 (persist documensoId early): ~20 min including a test that verifies the rollback still voids correctly.
- Fix #2 (cancel/delete affordance audit): ~2h depending on how many call sites exist.
- Fix #3 (UI orphan warning): ~1h.
- Fix #4 (reconciliation script): ~2h.
- Cross-refs:
- The earlier finding (above) — "Documenso upload — silent partial-state when field placement fails" — fixes the rollback path going forward. THIS finding addresses the orphans created BEFORE that fix landed + the cancel/delete affordances that miss the void path generally.
- Pairs with the comprehensive Documenso upload audit (Bucket 3 — referenced above as
Documenso upload — silent partial-state ...).
- Open questions for the user:
- Which "remove" action did you click — the per-row kebab in the EOI tab, the EoiCancelDialog, the docs hub kebab, or the document detail page Cancel/Delete button? Knowing which path you used narrows the diagnosis.
- Is the orphaned envelope in Documenso still there (you said you deleted from Documenso side too — did that succeed)? If yes, the orphan is gone and the CRM-side cleanup is the only remaining work. If no, we need the manual repair pattern in the meantime.
- Do you want a one-time admin script that scans for orphaned Documenso envelopes / dangling CRM rows now (to clean up everything created during this UAT session), or is that overkill and you'd rather just nuke the dev DB?
Document signing flow — copy-link parity across surfaces
SHIPPED locally (not yet committed)- Files touched: src/components/documents/signing-progress.tsx (the canonical shared component).
- React-grab anchor:
<div class="relative flex i..." />inSigningProgressinActiveEoiCardinInterestEoiTab. - Symptom: rep wanted to copy a signer's signing link to send via WhatsApp / Slack / in person, but the per-signer row only showed "Send invitation" (or "Send reminder") — Copy link wasn't visible because it was rendered behind a conditional that hid the button entirely when
signingUrlwas falsy. So if Documenso hadn't issued the URL yet, or the field wasn't populated on the signer record, the rep couldn't copy at all and had no signal that copy was even an option. - Root cause: the previous render at signing-progress.tsx:400 read
{signer.status === 'pending' && signer.signingUrl ? <CopyButton /> : null}— both pending status AND a non-empty URL were required. Reps with a freshly-created envelope (URL not yet on the row) saw only the Send invitation button. - Fix applied: changed the condition to render the Copy link button whenever
signer.status === 'pending', and disable the button (with a clarifying tooltip — "Signing URL is not available yet — Documenso issues it once the document has been sent.") whensigningUrlis missing. Available tooltip: "Copy this signer's signing link to your clipboard so you can share it directly (Slack, WhatsApp, in person) without going through email." Style upgraded fromghosttooutlineso it reads as a peer action to Send invitation / Send reminder instead of a tertiary affordance. - Surface coverage: SigningProgress is the single canonical signing-progress component (used by ActiveEoiCard / InterestReservationTab / InterestContractTab / DocumentDetail / DocumentDetail signers section via #67 doc-detail polish). One fix lands everywhere.
- Alternatives considered + rejected:
- Always show "Copy link" enabled and silently fail when URL is missing — rejected; reps would copy emptystring and ship a broken link in chat.
- Show "Copy link" only after invitation is sent — rejected because the design comment (line 388–393) explicitly calls out reps wanting to preview / share the URL BEFORE the formal email goes out.
- Effort: ~10 min for the condition flip + tooltip; ~0 min for the cross-surface coverage because SigningProgress is shared.
- Cross-refs: the prior session shipped the Documenso v2 distribute-response field plumbing that populates
signingUrl(c4450ddlineage). This finding is the UI follow-up. - Acceptance criteria: every pending signer row in every document signing surface shows BOTH a Copy link button (disabled when URL not yet issued, tooltip explaining why) AND the appropriate Send invitation / Send reminder primary action.
UploadForSigningDialog — "Recipient" label is too thin for a load-bearing choice
SHIPPED locally (not yet committed)- Files touched: src/components/documents/upload-for-signing-dialog.tsx (FieldSidePanel, ~line 1399).
- React-grab anchor:
<label class="font-medium pee...">Recipient</label>inLabelinFieldSidePanelatupload-for-signing-dialog.tsx:1376:4. - Symptom: the FieldSidePanel — the right-hand "Field properties" panel that opens when the rep selects a placed signature/text/date/checkbox field on the PDF — labels its signer-assignment dropdown with the single bare word
Recipient. The user flagged this as non-descriptive: the field is load-bearing because it determines which of the document's recipients will see and fill that specific field at signing time. A wrong selection sends the field to the wrong person; a confused rep skips the step and Documenso defaults to the first recipient. "Recipient" by itself doesn't communicate any of that — it reads like a passive metadata label, not an active assignment choice. - Root cause: the panel was scaffolded as a generic Type / Recipient / Value triplet without UX copy. The Select dropdown DOES populate correctly (recipients come from the dialog's
recipientsprop with#order Name/Emailformatted), so the wiring is fine — the gap is purely the label + a missing explainer. - Fix applied:
- Label text changed from
Recipient→Assign this field to. Active verb makes it clear this is a deliberate choice the rep is making, not a metadata read-out. - Helper paragraph added below the dropdown: "Whoever is selected here is the only person who will see and fill this field when the document is sent for signing." Plain English, explicit consequence.
- Label text changed from
- Alternatives considered + rejected:
- Renaming to "Signer" alone — rejected because the document recipient list can include CC / approver roles that aren't strictly signers, and "Signer" implies they sign.
- Using a per-recipient color-coded chip strip instead of a dropdown — rejected because reps frequently need to assign 10+ fields across multiple recipients in dense forms; a dropdown is faster than chips at that volume. Could be a future enhancement bundled with field-placement keyboard shortcuts.
- Effort: ~5 min (the fix itself). The rejected color-coded-chip alternative would be ~2h.
- Cross-refs: prior session shipped
c4450dd(field metadata panel + payload extension); this is a follow-up polish on the same panel. - Acceptance criteria: the FieldSidePanel's recipient-assignment row reads "Assign this field to" with the helper sentence below, and the dropdown still populates the document's recipients in signing-order with
#order Name/Emailformatting.
Recommender card — Heat badge needs explainer tooltip
SHIPPED locally (not yet committed)— src/components/interests/berth-recommender-panel.tsx (RecommendationCard Heat badge).- Symptom: "Heat 81" badge rendered with no explanation of what the number means. The tier badge next to it already has a Popover; the heat badge was a plain span.
- Fix applied: badge converted to a Popover trigger. Popover surfaces the headline ("Heat score · 81 / 100"), explains the formula in plain English ("how warm this berth is for a re-pitch — recency × furthest stage × interest count × EOI count"), shows the four component scores from
rec.heat.*, and notes that admins tune the weights in Admin → Recommender.
Recommender card — area letter duplicates mooring number
SHIPPED locally (not yet committed)— src/components/interests/berth-recommender-panel.tsx (RecommendationCard header).- Symptom: card rendered
E1followed by a separate "E" label. Mooring number already carries the area letter as a prefix (canonical^[A-Z]+\d+$per CLAUDE.md), so the standalone area letter was pure visual noise — same complaint as the BerthPicker fix earlier this session. - Fix applied: removed the area-letter span from RecommendationCard.
Recommender tier contradicts berth status
OPEN— src/lib/services/berth-recommender.service.ts:223 (classifyTier) + src/components/interests/berth-recommender-panel.tsx (card render).- Symptom: berth D39 shows both
Under Offer(status pill) ANDOpen(recommender tier). The tooltip definition contradicts itself: "Open: never had an interest, ready for new prospects." - Root cause:
classifyTieronly reads frominterest_berthsaggregates (active count / lost count / max active stage). A berth whoseberths.statuscolumn saysUnder Offer— set manually by an admin, imported from NocoDB, or left over from a stale row — has zero entries in interest_berths if no active interest is currently driving the status, so the tier classifier returns A (Open). The two signals come from different sources and aren't reconciled. - Fix: add
berthStatustoTierInputsand biasclassifyTier:- If
berthStatus === 'Sold'→ return'D'(treat sold the same as a late-stage active interest, since the rep should treat it as effectively closed; we still surface it as a backup option). - If
berthStatus === 'Under Offer'ANDactiveInterestCount === 0→ return'C'(someone is on it according to the public map even if interest_berths doesn't know who). The competing-interest chip from the previous finding then surfaces who that someone is. - Otherwise fall through to existing rules.
- If
- Alternative considered: filter Under Offer / Sold berths out of recommendations entirely. Rejected because reps DO use the recommender to surface backup options for "this might fall through" planning. The tier should just match the reality.
- Effort: ~30–45 min (TierInputs widen + plumb berth status through the aggregator query + adjust the tooltip copy so "Open" / "Active interest" labels stay coherent).
Berth occupancy info — surface competing interest on every non-available status
OPEN— src/components/interests/linked-berths-list.tsx LinkedBerthRowItem + src/components/interests/berth-recommender-panel.tsx (recommendation cards at ~line 184) + src/components/interests/interest-berth-status-banner.tsx (deal-level banner — already shipped).- React-grab anchors:
<span>Under Offer</span>in StatusPill in LinkedBerthRowItem; same pill in the recommender card body. - Symptom: anywhere a berth's status renders as "Under Offer" / "Sold" / "Reserved" the rep currently has no idea WHO is responsible for that status. They have to navigate to the berth detail page (or guess) to find the competing interest or the closed-deal client.
- Fix: reuse the existing
/api/v1/berths/[id]/active-interestsendpoint (shipped for the columns popover +InterestBerthStatusBanner) and surface the top competing interest inline on every non-available status surface. Show client name + stage pill + a link to the competing interest detail. Hide when the only competing interest is the current one (self-conflict makes no sense to flag). - Recommended implementation: extract a small
<BerthOccupancyChip berthId={...} excludeInterestId={currentInterestId} />component that runs the query, renders the chip when there's something to surface, and shares behaviour across:LinkedBerthRowItem(per linked berth on the interest detail)BerthRecommenderPanelrecommendation card body (per recommended berth)InterestBerthStatusBanner(deal-level banner — already does this; migrate to use the shared chip so the rendering stays consistent)berth-columns.tsxactive-interests popover (already exists; keep its richer multi-row popover, but reuse the data fetcher).
- Effort: ~1.5–2h. Single new shared component + 3 call-site adoptions + the deal-level banner migration. Closes the "who owns this berth right now" gap platform-wide in one pass.
NotesList source badge — clickable navigation to source entity
SHIPPED locally (not yet committed)— src/components/shared/notes-list.tsx.- Symptom: the "Yacht · Test Yacht" badge on aggregated notes (e.g. on a client's Notes tab, surfacing a note left on their linked yacht) was a plain
<span>— no way to pivot from the note to the source entity without leaving the page. - Fix applied: badge is now a
<Link>to the source entity's detail page whensourceIdis available (clients/companies/yachts/interests/residential variants all covered). NewsourceLinkFor(portSlug, source, sourceId)helper centralises the URL mapping.stopPropagationkeeps any outer row-click handler from interfering.
Notes tab header count doesn't aggregate
OPEN— src/lib/services/clients.service.ts:441-444 (clientNotes count) + similar in yachts.service / companies.service.- Symptom: rep adds a note to the client's linked yacht; client detail's Notes tab badge still reads "0" because the count only includes direct
client_notesrows. The NotesList itself shows the aggregated yacht / company / interest notes correctly — the tab badge is the only piece that's not aggregated. - Fix: extend the count to mirror the aggregator's symmetric-reach. For a client:
count(client_notes WHERE client_id=X) + count(yacht_notes WHERE current_owner_type='client' AND current_owner_id=X) + count(company_notes joined via company_memberships) + count(interest_notes joined via interests.client_id). Mirror for yacht / company tab counts. ~1–2h with vitest for the join logic.
Admin toggle to disable Tenancies entirely
PARTIALLY SHIPPED— backend exists, admin UI missing. src/lib/services/tenancies-module.service.ts (disableTenanciesModule(portId)+ companionisTenanciesModuleEnabled+ thetenancies_module_enabledsetting) + src/app/api/v1/admin/tenancies-module/*.- Symptom / user ask: rep is in "pure sales mode" — doesn't want Tenancies spilling into the UI yet. Wants an admin-level switch to turn the module off so the sidebar entry / entity tabs / dashboard widgets / top-level page all hide.
- Status: the platform already supports this (per docs/tenancies-design.md §"Platform-wide module-enabled rule"). What's MISSING is the admin Operations toggle in the settings UI: a Switch wired to
POST /api/v1/admin/tenancies-module/enable/POST .../disable, with the disable path showing a confirmation modal ("This will hide N existing tenancies — data is preserved but invisible until re-enabled. Continue?"). Per the design doc the helper copy reads: "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record." - Fix: add the Switch to
src/app/(dashboard)/[portSlug]/admin/operations/page.tsx(or wherever the operations settings live), wire to the existing endpoints, gate behindadmin.manage_settings. ~45 min.
Activity feeds — generic "updated this record" hides real changes
PARTIALLY SHIPPED locally (yacht transfer only)— src/lib/services/yachts.service.ts:215 (transferOwnership) + src/components/shared/entity-activity-feed.tsx:26 (ACTION_VERBS) + every other service that writes audit_log entries withaction: 'update'and nofieldChanged.- Symptom: EntityActivityFeed reads audit_logs and falls back to "X updated this record" when the row has no
fieldChanged. Major lifecycle events (yacht owner transfer, interest stage transitions, berth status flips, document state changes) write that exact generic row, so the feed loses ALL useful detail — defeats the audit-trail purpose. - Yacht-transfer fix shipped:
transferOwnershipnow resolves both the old + new owner names (client → fullName / company → name), writes the audit row withaction: 'transfer',fieldChanged: 'owner',oldValue: oldOwnerName,newValue: newOwnerName, plus reason/notes in metadata. EntityActivityFeed'sACTION_VERBSgainstransfer → 'transferred'. Result: "Matt transferred owner to Jane Smith" instead of "Matt updated this record." - Still open — sweep across every audit-log writer: every other service emitting
action: 'update'with nofieldChanged(or with an object asnewValue) needs the same treatment. Pattern: discrete action verb + named field + human-readable old/new values. Candidates surfaced in earlier audits: interest stage transitions, berth status flips, document send / sign / cancel events, eoi auto-cancel, tenancy activate / end / transfer, payment record/delete. Each is ~10min of service-layer surgery; the bulk is the sweep.
Activity feed UI — standardize across every entity surface
OPEN— src/components/shared/entity-activity-feed.tsx (the shared primitive) + every page that mounts an activity feed (client, interest, yacht, berth, company, tenancy, document).- Symptom: the user judges the client + interest activity feeds as the best-presented; other surfaces feel inconsistent. The shared
EntityActivityFeedIS the same component across consumers, so the visual difference must be in (a) which surfaces still use a bespoke per-entity feed rather than the shared one, or (b) which surfaces pass which props (filters, empty-state copy, session-grouping window). - Fix: audit every place an activity feed renders. Anything that's bespoke gets migrated to the shared
EntityActivityFeed. Anything that already uses the shared component but passes weak props (no filter dropdowns, no session collapsing) gets brought up to the client/interest baseline. Bundle with the audit-log content sweep above so the entries the feed renders are also comprehensive.
CompanyPicker — empty on open
SHIPPED locally (not yet committed)— src/app/api/v1/companies/autocomplete/handlers.ts + src/lib/services/companies.service.ts:303.- Symptom: CompanyPicker popover opens empty even though the port has companies on file. Has to type something before any options surface.
- Root cause: the autocomplete handler returned
{ data: [] }immediately whenqwas empty; the picker fires its first query withdebounced='', so the list was always empty on first open. - Fix applied: empty
qnow returns the 10 most-recently-updated companies for the port (still capped to 10, matching the typed-search path). Non-emptyqkeeps the existing ilike-match.
Yacht transfer dialog — drop "atomic" from copy
SHIPPED locally (not yet committed)— src/components/yachts/yacht-transfer-dialog.tsx:136.- Symptom: dialog description says "The change is auditable and atomic." — "atomic" is engineering jargon, doesn't mean anything to a normal user.
- Fix applied: rewrote to "The change is logged in the audit history." Same meaning, no jargon.
ClientTenanciesTab — pending tenancies invisible
SHIPPED locally (not yet committed)— src/lib/services/clients.service.ts:415.- Symptom: rep creates a tenancy via "Create tenancy" (status
pending), sidebar Tenancies entry surfaces (lazy module flip works), but the client detail's Tenancies tab shows the empty state. Same for any pending tenancy auto-created from a signed Reservation Agreement webhook before the rep confirms activation. - Root cause:
clients.service.getByIdfiltersactiveTenanciestostatus === 'active'only. Pending rows fall outside that filter and never reach the tab. - Fix applied: filter widened to
inArray(status, ['pending', 'active']). TheTenancyListcomponent already renders a status pill per row so the rep distinguishes pending from active without a section split.
TenancyList rows — not clickable to tenancy detail
SHIPPED locally (not yet committed)— src/components/tenancies/tenancy-list.tsx.- Symptom: rows in the Tenancies sections (client tab, berth tab, yacht tab, top-level
/tenancies) carry per-cell links for berth / client / yacht but no way to open the tenancy itself. Reps had to click the contract link or hunt for an edit affordance. - Fix applied: rows now navigate to
/{portSlug}/tenancies/{id}on click. Inner links/buttons (BerthLink, ClientLink, YachtLink, "View contract") still fire their own behaviour because the click handler bails when the target is inside an<a>or<button>. Keyboard support: Enter/Space on the row also opens detail.
BerthPicker — area suffix duplicates the group heading
SHIPPED locally (not yet committed)— src/components/shared/berth-picker.tsx:141 (labelFor).- Symptom: every option rendered as
Berth A1 · A,Berth B5 · Betc. The mooring number is already prefixed with the area letter, and the dropdown groups options under area-letter headings. The trailing· Areads as visual noise. - Fix applied: dropped the area suffix from
labelFor— rows now readBerth A1,Berth B5. Group heading still carries the area context. Same fix lands across every consumer of BerthPicker (tenancy create / renew / transfer dialogs, interest form, linked-berths add, etc.) because the label is centralized.
Tag chips missing wherever StageStepper renders
OPEN— src/components/clients/client-pipeline-summary.tsx (StageStepper component + ClientInterestRow type + useClientInterests query) + src/components/clients/client-interests-tab.tsx (InterestRowItem) + every other call site that renders<StageStepper>.- React-grab anchor:
<div class="flex-1 truncate...">Qual.</div>in StageStepper in InterestRowItem in ClientInterestsTab. - Symptom: the InterestRowItem cards show berth label + stage badge + stepper, but no tag chips. Tags are first-class on interests everywhere else (detail page, list view) — the same chips should follow the StageStepper everywhere it appears so reps see "Hot lead / VIP / Returning client" context at a glance without drilling in.
- Fix: (a) extend
ClientInterestRowwithtags?: Array<{ id, name, color }>and surface fromuseClientInterests(/api/v1/interests?clientId=X). (b) Render a small tag-chip strip just above or below the StageStepper in InterestRowItem + every other StageStepper call site (currentlyclient-interests-tab.tsx:88, 263,client-pipeline-summary.tsx:224, 340). (c) Cap to ~3 chips with a "+N" overflow indicator so long tag lists don't blow up the row height.
New-document "Upload file" — unclear where the file lands
OPEN— src/components/documents/new-document-menu.tsx (the "Upload file" action) + src/components/files/file-upload-zone.tsx (no post-upload navigation cue).- React-grab anchor:
<button>...New document</button>in DocumentsHub PageHeader. - Symptom: clicking "Upload file" opens a dialog whose description says "File will be added to the current folder" — but doesn't name the folder. After upload, the dialog closes silently; the file appears somewhere but the rep has no toast / navigation cue confirming it landed. If they uploaded from the hub root with a different folder selected in the sidebar, they may not realize the file went to the selected folder rather than the root.
- Fix: (a) Dialog description names the destination folder explicitly ("File will be added to Clients / Matthew Ciaccio / Deal A1-A3" with breadcrumb chain). (b) Post-upload toast: "Uploaded
<filename>→ [folder breadcrumb]" with a "View folder" action that selects that folder in the hub. (c) Optionally auto-navigate to the destination folder on success when the upload originated from a different folder (defer this — toast may be enough).
Recent files — no link to folder or attached entity
OPEN— src/components/documents/documents-hub.tsx HubRootView (the "Recent files" panel) + src/lib/services/files.service.ts (list response shape).- React-grab anchor:
<h3 class="flex items-cent...">Recent files</h3>in HubRootView. - Symptom: each recent-file row only shows filename + size + date; the rep has to remember which client / interest the file belongs to. No CTA to jump into the parent folder either.
- Fix: extend row payload with
{ folderId, folderName, clientId, clientName, interestId, interestBerthLabel }. Render a small badge column showing the attached entity (client name or interest's berth label, like the EntityFolderView pattern already shipped). Right-hand action gains an icon button "Open folder" that navigates to the folder view in Documents Hub.
Bucket 2 — Medium (15 min – 2 h)
Supplemental-info form — no port branding, no logo on top
OPEN— src/app/public/supplemental-info/[token]/page.tsx + src/components/shared/branded-auth-shell.tsx + src/lib/services/supplemental-forms.service.ts (loadByToken).- Symptom: opening a token link lands on the form with no logo / branding — the page sits OUTSIDE the
(portal)route group (relocated 2026-05-21 to dodge the portal kill-switch) and no<AuthBrandingProvider>wraps it, soBrandedAuthShellfalls back to neutral. - Fix: extend
loadByTokento also returnbranding: { logoUrl, backgroundUrl, appName }resolved viagetPortBrandingConfig(token.portId); the API surfaces it; the page passes it toBrandedAuthShellvia the explicitbrandingprop.
Supplemental-info form — extends edge-to-edge on long forms
OPEN— src/components/shared/branded-auth-shell.tsx.- Symptom: the form's card is taller than the viewport, but the shell uses
fixed inset-0 flex items-center(right for short auth surfaces), so the form content butts against the top/bottom of the viewport with no breathing room. - Fix: switch this page to a scrollable layout — natural page flow with padding above/below the card, logo at the top, footer note at the bottom. Don't touch the existing auth surfaces (login / reset-password / set-password / activate); they keep the
fixed inset-0shell.
Supplemental-info form — address fields incomplete
OPEN— src/app/public/supplemental-info/[token]/page.tsx + src/app/api/public/supplemental-info/[token]/route.ts + src/lib/services/supplemental-forms.service.ts.- Symptom: form has a single "Address" textarea + Country combobox. The CRM's
client_addressestable holds: street, city, subdivisionIso (region/state), postalCode, countryIso. The form drops most of those. - Fix: match the CRM shape — separate inputs for street + city + subdivision (SubdivisionCombobox) + postal code + country. Plumb through prefill API + submission validator + applySubmission diff/persist.
Supplemental-info form — no context about where details land
OPEN— src/app/public/supplemental-info/[token]/page.tsx.- Symptom: form header says "A few details before we draft your EOI" but doesn't surface that the exact values entered will appear on the legal document. Setting that expectation up-front reduces support questions about why we're asking.
- Fix: add a small info banner ("These details will appear on your Expression of Interest document") near the top, soft tone, doesn't disrupt the flow.
Marketing-site form parity — primary surface lives on the website
OPEN(cross-repo) — docs/marketing-site-followups.md for the spec; CRM keeps the/public/supplemental-info/[token]route as fallback.- Symptom / direction: the marketing site should host the public-facing supplemental-info form (and any other public client forms, e.g. the EOI pre-flight intake) so the polish matches the rest of the public surface. The CRM-hosted page stays as the operator-safe fallback if the marketing site is down or not pointed at.
- Fix: document the API contract in
docs/marketing-site-followups.md(route, payload shape, prefill response, submission schema, token expiry behaviour) so the marketing-site team can build the equivalent. Per-port hardcoded form layouts are fine on the marketing-site side; the CRM API stays generic.
Interest OverviewTab — inherit empty fields from client + visually denote
OPEN— src/components/interests/interest-tabs.tsx (OverviewTab) + src/lib/services/interests.service.ts (response payload).- React-grab anchor:
<div class="space-y-1" />in OverviewTab, inside the TabsContent presence wrapper. - Symptom: the OverviewTab shows interest-level fields that, when empty, render as " - ". If the client (or linked yacht for dimensions) already has those details on file, the rep has to navigate to the client / yacht to see them. Adds friction + risks reps re-asking the client.
- Fix: when an interest field is null but the client/yacht has it filled, render the inherited value with a small visual cue (e.g. italic + a "from client" or "from yacht" pill). Editing in place should write to the interest's own column (override). Specific candidates:
- Berth requirements (desiredLengthFt/widthFt/draftFt) → fall back to linked yacht's lengthFt/widthFt/draftFt.
- Email/phone — already shown via
ClientChannelEditorwhich reads client-level; the inheritance is implicit there but no visual indicator exists for "this came from client primary contacts." - Address / country — interest has no address column; if shown on Overview, it's a pure read of the client's primary address (visual indicator helps reinforce that editing here updates the CLIENT, not just this deal).
- Open question: should editing an inherited dimension write to the interest (override, deal-specific) or to the yacht (correct the yacht record)? Default proposal: write to the interest (override pattern) and offer a follow-up CTA ("Update yacht record too?").
Bucket 3 — Features / larger (> 2 h)
Documenso rejection reason — pull through + surface to the rep
SHIPPED locally (not yet committed) — backend; UI surfacing queued- Files touched:
- src/app/api/webhooks/documenso/route.ts (
DocumensoRecipienttype extended withrejectionReason+declineReason; DOCUMENT_REJECTED / DOCUMENT_DECLINED handler now coalesces the two field names and passes through). - src/lib/services/documents.service.ts (
handleDocumentRejectedsignature gainsrejectionReason?: string | null;document_events.eventDatastores it; audit log metadata carries it; the in-CRM notification description quotes it inline, truncated at 120 chars with full reason still in the audit row).
- src/app/api/webhooks/documenso/route.ts (
- User's question: "are we able to pull through the rejection reason through the API if a signer rejects the document through documenso? if so we need to pull it through and append it."
- Answer: yes — Documenso sends the cleartext reason on the recipient object (
rejectionReasonon v2; some 1.x payloads use the legacydeclineReason). Up to this fix we were ignoring both. Now coalesced + persisted + surfaced. - Where the reason now appears (after this fix):
document_eventsrow →eventData.rejectionReason(the audit timeline can render it).audit_logsrow →metadata.rejectionReason(admin's audit-log viewer surfaces it).- In-CRM rep notification → inline in the description quoted in ASCII quotes, truncated to 120 chars so the bell tile doesn't wrap awkwardly. Example:
matt@letsbe.solutions declined to sign: "The deposit amount needs to be £20k not £30k" — review and regenerate.
- Still queued (UI surfacing): EOI tab + InterestEoiTab status banner should also render the rejection reason inline below the "EOI declined" headline. Right now the banner just says rejected without surfacing the why. ~30 min to wire — query the latest
document_eventsrow of type=rejectedfor the active EOI and pluckeventData.rejectionReason. Bundle with the next round of EOI-tab polish. - Cross-ref: the broader "Activity feed comprehensive copy" finding above — both are about pulling raw signal out of audit_logs / document_events and rendering it as actionable copy instead of generic "updated this record" / "EOI declined." Pattern: every domain event should carry domain-meaningful detail through to the UI.
Documenso rejection — UI didn't reflect rejected state; poller fallback was missing the REJECTED branch
PARTIALLY SHIPPED locally (poller fixed; webhook URL auto-update + admin health-check queued)- Confirmed root cause (per user): Documenso webhooks were configured to a stale cloudflared tunnel URL (quick-tunnels rotate hostnames on restart). Documenso was POSTing into a dead host. The CRM never received the rejection event. User confirmed: "the webhooks aren't working because they're a cloudflare tunnel link that is set in the crm but no longer works".
- Secondary root cause (discovered while fixing): the existing
signature-pollBullMQ job runs every 5 minutes viasrc/lib/queue/scheduler.ts:21and is the documented fallback for missed webhook deliveries — but it did not handle the REJECTED / DECLINED path at all. It only reconciled SIGNED (recipient), COMPLETED (document), and EXPIRED (document). A rejected document polled by this job saw no matching branch and exited silently. So even with the polling fallback running, rejections were invisible to the CRM. User reasonably asked: "shouldn't the API be polling for updates to signatures/document stuff in the absence? Is the system not checking if the webhook works, or is there no way to do so?" - Files touched (this fix):
- src/lib/services/documenso-client.ts:157 (
normalizeDocument) — recipient shape now coalescesrejectionReason??declineReasonand surfaces it on every poller / direct-fetch consumer. - src/lib/services/documenso-client.ts:213 (
DocumensoDocument.recipients[]) — gains optionalrejectionReason?: string. - src/jobs/processors/documenso-poll.ts — new
else ifbranch forremoteDoc.status === 'REJECTED' | 'DECLINED'. Finds the rejecting recipient, plucks the reason, hands off tohandleDocumentRejectedwith the same shape the webhook receiver uses — sodocument_events, audit log, notification, and UI all converge on identical state regardless of delivery path. - src/lib/services/documents.service.ts:1920 (
handleDocumentRejected— already-extended in the earlier rejection-reason finding) — acceptsrejectionReason?: string | null, stores ondocument_events.eventData, surfaces in the rep notification description, persists in audit log metadata. - src/app/api/webhooks/documenso/route.ts (already-extended earlier this turn) — DOCUMENT_REJECTED / DOCUMENT_DECLINED handler coalesces the reason and passes through.
- src/lib/services/documenso-client.ts:157 (
- Result of this fix: even with a broken tunnel, the rejected document will converge to
status='rejected'within 5 minutes of the nextsignature-polljob tick. The rep gets the notification, the EOI tab status pill flips, audit log carries the rejection reason. Webhook is now an OPTIMISATION (sub-second), not a CORRECTNESS REQUIREMENT. - Still queued (higher-value follow-ups):
- Auto-update Documenso's webhook URL on tunnel restart.
./scripts/tunnel-url.sh --copyalready prints the URL; extend it to also POST to Documenso's webhook-update API endpoint using the same API key the CRM uses for envelope creation. One command rotates the URL on every dev session. Add a LaunchAgent post-start hook so this happens automatically when the tunnel-service restarts. - Admin "Webhook health" page. New page at
/admin/integrations/webhooksthat surfaces: last-received timestamp per webhook event type (so a multi-day gap is visible), count of webhooks received in the last 24h vs documents created in the same window (the ratio should be ~1:1 in a healthy port), a "Test webhook delivery" button that posts a synthetic test event and waits for the round-trip. ~3–4h. - Periodic divergence alarm. Cron job (separate from
signature-poll): if more than X documents are stuck in'sent'for > Y hours, fire an alert to super admins so they investigate webhook / Documenso config. ~1h once the alert infra is settled. - Document the "re-paste tunnel URL into Documenso after every tunnel restart" gotcha in CLAUDE.md until the auto-PATCH lands. ~5 min.
- Auto-update Documenso's webhook URL on tunnel restart.
- Why polling alone isn't enough long-term:
- Latency: 5-min worst case until the CRM converges. Reps watching for a fresh signature don't want to wait 5 minutes.
- Cost: per-poll
getDocumentcall per in-flight doc per 5 min × N ports = noticeable Documenso API traffic at scale. - Webhooks remain the right primary path; polling is the safety net. Both should work.
- How the user can verify the fix right now:
- Run
./scripts/tunnel-url.sh --copy, paste the URL into Documenso webhook settings (Documenso → Settings → Webhooks → edit the existing one → paste new URL → save). The webhook is now reachable for the next test. - Alternatively (without fixing the tunnel), wait up to 5 minutes — the poller will pick up the existing rejected doc and reconcile it. Watch the EOI tab; status pill should flip from AWAITING SIGNATURES to REJECTED.
- Run
- Cross-refs:
- The "Documenso upload comprehensive audit" finding (Bucket 3 above) — bundle with that audit since both are about Documenso ↔ CRM state convergence under failure modes.
- The "Documenso rejection reason — pull through" finding above — same chain of changes; the poller fix completes the rejection-reason-everywhere arc.
- Open questions for the user:
- Should the auto-PATCH of Documenso's webhook URL on tunnel restart happen unconditionally, or behind a feature flag (
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1) so prod ports can't accidentally have their webhook URL rotated by a stale dev script? My recommendation: env-flag-gated. - What should the admin Webhook Health page do for ports with NO webhooks ever received? Render a "not yet tested" empty state, or auto-fire a synthetic test on first page load? Default proposal: explicit "Test now" button — surprise-auto-firing webhooks on a fresh admin visit is wrong.
- Should the auto-PATCH of Documenso's webhook URL on tunnel restart happen unconditionally, or behind a feature flag (
Documenso signing order — does template's SEQUENTIAL win or does CRM override?
ANSWER + clarifying fix queued- User question: "is the signing order we designate overridden by the template signing order set in the documenso app when I make a template?"
- Files inspected:
- src/lib/services/documenso-client.ts:462-499 (template-use → envelope-update post-create flow).
- src/lib/services/documents.service.ts:813 (
docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}spread on EOI generate call). - src/lib/services/port-config.ts (getPortDocumensoConfig returns
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null).
- Answer: the CRM's per-port
documenso_signing_ordersetting overrides the template's stored signing order — but only when the port setting is explicitly set. Mechanism:/template/usecreates the envelope from the template. Documenso v2's template-use endpoint silently drops themetafield on the request body — signingOrder/subject/message/redirectUrl all inherit from the template's stored defaults. (See the comment at documenso-client.ts:464.)- The CRM then patches
/envelope/updatewhile the envelope is still DRAFT to apply per-port overrides. This update can set signingOrder. - At documenso-client.ts:472-476 the update only includes
signingOrder(and the other meta fields) when the value is non-empty. If the port'sdocumenso_signing_ordersetting is empty/null, the update skips that field and the template's stored value (SEQUENTIAL in your case) is preserved. - At documents.service.ts:813 the signingOrder is only PASSED to the create call when truthy. Same logic — empty port setting means template wins.
- Implication for the user's port: if the EOI currently shows "Concurrent" but the template is SEQUENTIAL, your port's
documenso_signing_ordersetting is set toPARALLEL(overriding the template). Check at Admin → Documenso → Behavior → signing order. Either flip it to SEQUENTIAL (forces sequential regardless of template) or clear it tonull(defers to whatever the template specifies, which would honour your SEQUENTIAL template). - Suggested UX fix (capture as
OPEN): the admin settings form fordocumenso_signing_ordershould offer three values, not two:SEQUENTIAL,PARALLEL, andUse template default(the empty/null state). Today it's a binary toggle that hides the "defer to template" option. A rep configuring per-port settings can't easily express "I want the template to win" without knowing to leave the field blank. - Cross-refs: ties into the Automate Signing finding directly below — automation behaviour DEPENDS on signing order semantic, so they should ship in the same wave.
Automate signing — single button that cascades invites + emails the completed doc (REFINED with signing-order awareness)
OPEN— feature request, no implementation. Refined below to handle both sequential AND concurrent modes properly.- Files implicated (once built):
- src/components/documents/active-eoi-card.tsx (new "Automate signing" button + state visualisation).
- src/lib/services/documents.service.ts (new
automateSigning(documentId, portId)orchestrator). - src/lib/services/documenso-client.ts (already has
sendDocument+sendReminder; may needsetSigningOrder('SEQUENTIAL')mid-flight if the doc wasn't created sequential). - src/app/api/webhooks/documenso/route.ts →
handleRecipientSigned(today only updates the row; needs a branch that fires the NEXT signer's invite when the envelope is in "automated" mode). - src/lib/db/schema/documents.ts — new column
documents.automation_mode: 'manual' | 'sequential_auto' DEFAULT 'manual'. - src/lib/email/templates/ — new template
signing-completed-recipient-bundle.tsxfor the all-done broadcast with signed PDF attached (already 80% there —compose-completion-emailroute exists perdocument-detail.tsx:217).
- React-grab anchor:
<section class="rounded-xl bord..." />inActiveEoiCardinInterestEoiTab. - User's request (verbatim): "there should also be something like an 'Automate Signing' button where it sends out an auto invite to the signers in order one after the other as they sign, then send them all a confirmation email with the signed document attached when done."
- Proposed feature spec (two-mode):
- New button on ActiveEoiCard: "Automate signing" — visible when (a) the doc has ≥2 signers, (b) status is
draft(Documenso has the envelope but no invite has gone out yet), (c) the rep hasdocuments.sendpermission. Same conditions as the existing per-row "Send invitation" CTA but operates over the whole flow. - On click: the dialog branches based on the document's signing order (which the CRM reads from the envelope via
getDocumentor persists locally ondocuments.signing_orderat create time):- Concurrent / PARALLEL signing order: confirmation modal explains "All N signers will receive the invitation now. As each signs, you'll see their progress in real time. When everyone has signed, every recipient gets the completed PDF by email." Submission fires ALL signer invitations in parallel (single bulk dispatch) and sets
documents.automation_mode='concurrent_auto'. The webhook completion handler still fires the final broadcast email — same as sequential mode below. - Sequential / SEQUENTIAL signing order: confirmation modal explains "Documenso will route this in order. First we'll invite {firstSigner.name}. As each signer completes, the next invite fires automatically. When everyone has signed, every recipient gets the completed PDF by email." Submission fires only the first signer's invitation and sets
documents.automation_mode='sequential_auto'. Webhook handler fires next-in-order on eachrecipient_signed(logic below).
- Concurrent / PARALLEL signing order: confirmation modal explains "All N signers will receive the invitation now. As each signs, you'll see their progress in real time. When everyone has signed, every recipient gets the completed PDF by email." Submission fires ALL signer invitations in parallel (single bulk dispatch) and sets
- Webhook side (sequential mode only): in
handleRecipientSigned, after the existing row update, check the parent doc'sautomation_mode. Ifsequential_autoAND there's a next-in-order signer withinvitedAt=NULLAND envelope status isn't completed, fire that signer's invitation. Concurrent mode skips this entirely (everyone already invited). Use the existing token + branded-invite path so the email is identical to a manually-fired invite. - On completion (
handleDocumentCompleted) — shared across both modes: ifautomation_modeisconcurrent_autoORsequential_auto, queue the existingcomposeCompletionEmailroute logic to send the signed PDF to every recipient (signers + CCs + approvers). Stays decoupled from the user-drivenemail-completionflow that already exists for manual mode. - UI state during automation (mode-aware):
- Sequential: ActiveEoiCard shows an "Automating · signer N of M" banner.
- Concurrent: banner reads "Automating · all N signers invited · 0 of N signed" and updates as signatures land.
- Both modes: per-row layout collapses to a status badge + the existing Copy link button (so reps can still manually share if they want a parallel channel).
- Both modes: A "Pause / Revert to manual" affordance lets the rep stop auto-firing mid-flow (set
automation_mode='manual').
- Why distinguish concurrent vs sequential automation: user noted that for concurrent, automation is just "send invites at once" — the cascade-as-they-sign logic only applies to sequential. Spec must NOT force a concurrent doc into a sequential cascade just because the rep clicked Automate. The signing order is preserved from the envelope; automation respects it.
- New button on ActiveEoiCard: "Automate signing" — visible when (a) the doc has ≥2 signers, (b) status is
- Why this matters: today the rep has to babysit a multi-signer doc: send invite #1, watch for webhook, send invite #2, repeat. For a 4-signer Reservation Agreement (common case per recent UAT screenshot) that's 4 manual button clicks across hours/days. Automation closes the gap between "Documenso supports sequential signing" and "the rep gets a one-click 'set it and forget it' workflow."
- Effort: ~6–8h end-to-end.
- ~30 min schema migration + Drizzle type update for the new column.
- ~1h orchestrator service function + permission gate.
- ~1h webhook branch (sequential-auto next-fire logic) + idempotency guard so two concurrent webhook deliveries don't double-fire.
- ~1h completion-email broadcast wiring (reuse
composeCompletionEmail). - ~1.5h ActiveEoiCard UI (button + confirmation modal + automating banner + pause CTA).
- ~1h vitest covering: automation enable → first invite fires; webhook signs → next invite fires; completion → broadcast email; pause mid-flow → no further auto-fires.
- ~30 min audit-log entries on enable / pause / auto-fire / broadcast.
- Alternatives considered + rejected:
- Auto-fire ALL invites at once instead of sequentially — rejected because Documenso's SEQUENTIAL signing order specifically means signers must wait their turn. Firing all invites at once + asking signers to wait is confusing UX.
- Defer to Documenso's native auto-send — rejected because Documenso's auto-send doesn't trigger our branded invite email path or our post-completion broadcast; the rep gets Documenso's stock emails instead of the per-port-branded templates we ship.
- Cross-refs:
documenso_signing_orderper-port setting (already exists per CLAUDE.md Documenso section).compose-completion-emailroute (document-detail.tsx:217 — partially built; this finding finishes the auto-broadcast half).- Pairs with the "Documenso upload comprehensive audit" finding above — both touch the upload-for-signing service. Bundle them as one focused Documenso polish wave.
- Open questions for the user:
- When the rep enables automation mid-flow (e.g. signer #1 was already manually invited), should the system pick up where they left off, or refuse and require the rep to start from a draft? Default proposal: pick up — find the next-in-order signer with
invitedAt=NULLand fire from there. Cleanest UX, matches what reps would expect. - Completion broadcast scope — does it include CCs and Approvers, or only the SIGNERs? Default proposal: everyone (the CC role exists specifically to get a copy at the end). If you want a different default, name it.
- Should the rep be able to PARTIALLY automate — fire invites automatically but stop short of the broadcast email? I'd say no for v1 (one workflow, one mode), but if your reps already split those steps mentally we could offer two distinct modes.
- Existing per-row "Send invitation" + "Send reminder" buttons during automation — keep them visible (as override) or hide entirely? Default proposal: keep them visible but show "Auto-firing soon" tooltip when the doc is in
sequential_auto. Reps retain manual control.
- When the rep enables automation mid-flow (e.g. signer #1 was already manually invited), should the system pick up where they left off, or refuse and require the rep to start from a draft? Default proposal: pick up — find the next-in-order signer with
/documents/new CreateDocumentWizard — confusing, redundant pathways
OPEN— src/components/documents/create-document-wizard.tsx + src/components/documents/new-document-menu.tsx + src/app/(dashboard)/[portSlug]/documents/new/page.tsx.- React-grab anchor:
<section class="rounded-md bord..." />in CreateDocumentWizard in NewDocumentPage.
Current state — three flows wired three different ways:
| # | What | Entry point today | Underlying mechanism |
|---|---|---|---|
| 1 | Generate an EOI/Contract/Reservation from a template, send through Documenso | EoiGenerateDialog (interest tab) OR /documents/new wizard → Generate from a template + pathway = documenso-template |
Template synced to Documenso; CRM calls the Documenso template-generate endpoint with merge-field values; Documenso renders + distributes for signing. |
| 2 | Upload an arbitrary PDF, place fields manually, send through Documenso | UploadForSigningDialog (interest tabs + NewDocumentMenu dropdown) |
PDF uploaded to storage; rep drags signature/text/date/checkbox fields onto the PDF preview; CRM POSTs PDF + field metadata to Documenso (field/create-many on v2 or the legacy placeFields on v1). |
| 3 | Upload a PDF that's already signed offline, mark it as signed | ExternalEoiUploadDialog (interest EOI tab) for EOIs; equivalent for Reservation/Contract on their tabs |
No Documenso involvement; service flips eoiStatus/reservationDocStatus/contractDocStatus to signed + advances stage; pure metadata operation. |
What's wrong:
- Wizard duplicates the dropdown.
NewDocumentMenualready exposes three named actions (Upload file / Upload & send for signature / Generate for signing) that map cleanly to flows 2/2/1. The wizard then takes the rep to/documents/new, where they pick AGAIN between "Generate from a template" and "Upload a finished PDF" — the upload branch is just flow 2 reimplemented worse (no field placement UI, just a stored file id). - The "inapp" template pathway is undocumented and probably unused. The wizard's pathway dropdown offers
documenso-template(rendered by Documenso) vsinapp(rendered by CRM via pdf-lib AcroForm fill, then sent to Documenso for signature). The inapp pathway exists in code but no UI feature surfaces it as a deliberate choice — it's a configuration trap. - Flow 3 (upload externally-signed) has no entry from the wizard or the dropdown. It's only reachable from the per-interest tabs, which is fine for EOI / Reservation / Contract, but means a rep who lands on
/documents/newcan't even ask for it. - Templates feel like a heavyweight concept. Reps want to "send an EOI to this client" — they shouldn't have to think about which template id maps to that.
Why templates exist (do we need them?):
Templates ARE needed for flow 1 — the generate-via-Documenso path. Documenso requires a pre-built template (with signature/text field placeholders) that lives on its side; the CRM provides merge-field values and Documenso renders the final PDF. We can't ship flow 1 without templates because Documenso's API requires a template id. They ARE NOT needed for flows 2 and 3.
The catch: most ports will have ~3 templates total (EOI, Reservation Agreement, Contract). Hiding the template picker behind a doc-type selector ("EOI" → uses the port's documenso_eoi_template_id setting) makes templates invisible to reps — they pick a doc type, the right template loads. Already half-implemented for EOI via documenso_eoi_template_id; needs the same treatment for Reservation + Contract.
Proposed redesign:
- Delete the wizard's upload branch. Flow 2 lives in
UploadForSigningDialogwhich is already the right surface. The wizard becomes generation-only. - Delete the pathway dropdown.
inappis dead; either remove it or surface it as an admin-only override. Default todocumenso-template. - Replace the template picker with a doc-type-driven default. Rep picks "EOI / Reservation Agreement / Contract" → wizard resolves the template id from per-port settings (
documenso_eoi_template_id,documenso_reservation_template_id,documenso_contract_template_id). For ports that want a non-default template, an admin-only "Use a specific template" override stays. - Surface flow 3 from the dropdown menu. Add "Mark as signed (uploaded offline)" as a fourth dropdown item that opens the appropriate external-signed dialog based on the current entity context.
- Drop
/documents/newas a route entirely. Replace with a<GenerateDocumentDialog>opened from the dropdown menu, matching the modal pattern the other flows already use. Saves a page navigation + keeps the entry pattern consistent.
Effort: ~6–8h end-to-end. Largest piece is the template-id resolution — needs the per-port settings keys for Reservation + Contract (if not already there) + wizard service migration. UI surgery is ~2h.
Open questions for the user:
- Confirm flow 3 (mark externally signed) should be reachable from the dropdown menu, not just from per-interest tabs.
- Confirm the
inapppathway can be removed (or do reps still need a CRM-rendered PDF for any edge case the audit hasn't surfaced?). - Confirm the per-port template-id pattern is the right way to hide templates from reps. Alternative: a one-time admin step to pick the default per doc type, with a "switch template" link visible to admins only.
CreateDocumentWizard — Reminders/Watchers/Signers leak into upload-only flow
OPEN— src/components/documents/create-document-wizard.tsx (the Signers + Reminders + Watchers sections render unconditionally regardless of source).- React-grab anchor:
<section class="rounded-md bord..." />in CreateDocumentWizard. - Symptom: the wizard currently shows Signers / Reminders / Watchers sections for every source path. For a flow-3 upload (rep already has the signed PDF, just wants to file it + tag it to an entity), none of those apply — there's no Documenso envelope, no signing event to remind about, no in-flight workflow to watch. The wizard's sections are written for the Documenso-driven flows and bleed into the upload case where they're noise.
- Fix: hide Signers + Reminders + Watchers when the rep's path is "upload a finished PDF that's already signed offline." Keep them visible for generate-via-template (flow 1) and upload-with-fields-for-signing (flow 2). Today the wizard conflates flow 2 with flow 3 via the misleading "Upload a finished PDF" label — the larger refactor (above) splits them; this fix piggybacks on that split.
- Bundle with: the wider wizard refactor — same set of edits. Don't ship this in isolation; the section visibility logic depends on the source-path being unambiguous.
CreateDocumentWizard subject picker — needs at-a-glance entity scan
OPEN— src/components/documents/create-document-wizard.tsx:328-370 (thegrid grid-cols-[max-content_1fr]subject row).- React-grab anchor:
<div class="grid grid-cols-..." />in CreateDocumentWizard. - Symptom: picking the document subject means choosing a type (Client / Company / Yacht / Interest / Tenancy) THEN searching that one type's picker. Reps don't think in terms of "what type is the recipient" — they think "I need to send this to deal X" or "this is for client Y." The two-step type-then-picker requires the rep to know the answer to the type question before they can search.
- Fix proposal: replace the type+picker pair with a single unified search field (same idiom as the global Command-search). Typing surfaces matching clients/companies/yachts/interests/tenancies inline, each row carrying its type label as a badge. Recent interactions surface first when the input is empty. The chosen entity sets both
subjectTypeandsubjectIdin one click. - Bundle with: the larger wizard refactor (above) — if
/documents/newbecomes a<GenerateDocumentDialog>, this is the natural place to ship the unified subject picker as one consistent pattern.
Bucket 4 — Bugs (severity-tagged)
None yet.
Append protocol
- One finding per entry. Don't bundle multiple distinct issues inside one bullet.
- Always tag status as the first inline tag:
OPEN | IN PROGRESS | SHIPPED in <hash> | SHIPPED locally (not yet committed) | PARTIALLY SHIPPED | QUEUED | BLOCKED. - Be incredibly detailed. Every finding should carry:
- File:line evidence across every layer touched (component + service + validator + migration when relevant — not just the visible component).
- React-grab anchor verbatim when the user pasted one (the
<tag class="..." />inComponentchain). - Symptom describing what the user saw + what they expected. Reference the screenshot's content when one was provided.
- Root cause — explain the actual mechanism (which query, which prop, which filter is wrong). When unknown, list ranked hypotheses.
- Fix proposal concrete enough that a future agent can implement without re-investigating. Name the functions, props, validators, migrations, query keys. Walk each layer in order when the fix touches multiple (service → API → UI).
- Effort estimate (hour range).
- Alternatives considered + rejected when there was a design call to make.
- Open questions for the user when a decision is pending — number them so the user can answer by reference.
- Bundle-with notes when the finding should ship together with another so related fixes don't drift.
- Cross-refs to related findings (by heading) and to shipped commits (by hash).
- Acceptance criteria when the fix is non-trivial — what does "done" look like?
- Always include file:line evidence when known — even a guess is better than none.
- Bucket by effort, not domain. Quick / Medium / Large / Bug. Cross-domain refactors that touch several files but each touch is small belong in Quick or Medium.
- Premature or aspirational items still queue. Reason: the project's feedback memory explicitly says don't silently filter; the finding belongs even if we won't act on it this session.
- Shipped entries keep their detail. When marking a finding SHIPPED, edit the status tag and append a "Fix applied:" paragraph below the original symptom + root cause. Don't strip the context — the queue is also the history.