Files
pn-new-crm/docs/superpowers/audits/active-uat.md
Matt b00cc24565 docs(audit): lock decisions from the 2026-05-26 question round
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>
2026-05-26 20:46:21 +02:00

89 KiB
Raw Blame History

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 new active-uat.md.

Started 2026-05-26 after the drain commit e9509dc cleared the prior alpha-uat-master.md long 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 started
  • IN PROGRESS — currently being worked on this session
  • SHIPPED in <hash> — committed; commit message has detail
  • QUEUED — not for this session; deliberately deferred
  • BLOCKED — waiting on user input / external repo / clarification

Severity (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 documensoId immediately after create, (2) pre-flight validation, (3) state-machine refactor with rollbackTo() 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 recipientIndex doesn't resolve. No override path.
  • /documents/new wizard refactor: (a) delete the upload branch, (b) drop the inapp template 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/new as 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_order setting: 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/deleteDocument service flow with permission check + Documenso void when documensoId set + 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

  • OPENsrc/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-dialog lg:max-w-4xl bump only fixed the dialogs I explicitly migrated; everything still using the default — including FilePreviewDialog (which overrides to max-w-4xl but PDFs are unreadable at that width) — stays cramped on desktop.
  • Fix: bump the Dialog primitive base to sm:max-w-2xl lg:max-w-4xl so 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 PROGRESSsrc/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_LABELS Record 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 other is 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). listFiles now honours the folderId filter that was already accepted by the validator. FlatFolderListing runs a sibling useQuery against /api/v1/files?folderId=X and merges both sources into a unified HubRow list sorted by createdAt desc. New renderFileRow renders 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: defaultValues set source: 'manual', but the !interest && open reset 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" /> in FieldPlacementStep in DialogBody.
  • 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:
    1. New RECIPIENT_ROLE_META constant maps each role to display label + tint (Signer blue, Approver amber, CC slate). New RecipientRoleBadge component renders the pill.
    2. 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 title for hover-truncation tolerance.
    3. 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.
  • 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 via fields.map(f => { if (!recipientId) throw ConflictError(...) ...}) BEFORE the surrounding try/catch. The synchronous throw from map() bubbled past the catch-and-rollback block that wraps placeFields(), so when the recipient lookup missed:
    1. Documenso envelope: already created + distributed (sendDoc succeeded earlier in the flow).
    2. Recipients: created with correct roles, signing URLs issued.
    3. Fields: never placed (the throw fired BEFORE the placeFields call).
    4. 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.
  • Fix applied (this session):
    1. The placements map() is now INSIDE the same try/catch that wraps placeFields(). Any throw — sync or async — triggers the rollback (Document row → cancelled, Documenso envelope → voided).
    2. 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.
    3. 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).
  • 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:
    1. Pre-flight validation BEFORE envelope creation. Validate every recipient row has a usable email; validate every placed field's recipientIndex resolves 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.
    2. Per-step rollback hardening. Currently rollback paths exist after documensoCreate, documensoSend, and placeFields, but they're independent try/catches. Refactor into a single sequenced state machine with an idempotent rollbackTo(step) helper so future inserts (e.g. metadata write between steps) inherit the rollback automatically.
    3. 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 in sentDoc.recipients; raise a specific error type if not.
    4. Idempotency on retry. If the rep hits Send twice (network blip), do we double-create envelopes? Verify the document row's documensoDocumentId is checked before another documensoCreate call.
    5. 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.
    6. 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.
  • Effort for comprehensive audit: ~610h. ~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/new wizard 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.
  • Open questions for the user:
    1. 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.
    2. 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..." /> in Card in BerthRecommenderPanel.
  • 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:
    1. Computed hasDesiredDims = toNum(interest.desiredLengthFt) !== null once near the top of the InterestTabs component, and once inside OverviewTab (because the Overview's inline render lives inside the child).
    2. Overview tab's BerthRecommenderPanel mount wrapped in {hasDesiredDims ? <Panel /> : null} — disappears entirely until length is captured.
    3. 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.
  • 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 = NULL shows 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..." /> in DismissableLayer in FocusScope in Presence (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:
    1. 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 have is_specific_interest=true, OR be defaulted based on those existing flags.
    2. External EOI upload dialog — same logic, parallel checkbox.
    3. Reservation generate / external upload — same pattern at a later stage.
    4. Bulk berth-tagging surfaces — less likely given the recent flow.
  • Root cause hypothesis: these dialogs currently default their map-flip checkbox to a static value (probably true), without reading the existing per-row is_specific_interest flags on the interest's interest_berths rows. 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):
    1. Query the interest's interest_berths rows when the dialog opens. Derive the default: if ANY in-bundle berth has is_specific_interest=true, default the dialog's checkbox to true. Otherwise default false.
    2. 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.
    3. Wire submit to honour those per-row toggles instead of a single global checkbox.
  • 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:
    1. 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.
    2. 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 title field through to documensoCreate(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 (documensoId is only written to the CRM row AFTER placeFields succeeds).
    • src/lib/services/documents.service.ts:648 (deleteDocument — best-effort void only runs if (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 (listDocuments filters out status='deleted' by default).
    • src/components/interests/interest-eoi-tab.tsx:121 (EOI tab query).
  • Symptom chain (UAT 2026-05-26):
    1. 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).
    2. Rep "removed the EOI" via the CRM UI — but the doc STILL displays as DRAFT in the EOI tab.
    3. Rep also confirms it wasn't deleted from Documenso side either.
  • Root cause (multi-part):
    • A. CRM lost the link to Documenso. Because step 1 left documensoId=NULL on the CRM row, both deleteDocument and cancelDocument skip 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 cancelDocument had run, status would be cancelled. If deleteDocument had run, the row would be filtered out of the EOI tab list (line 195 excludes status='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 placeFields left a phantom draft + orphaned envelope. Reproduced reliably until the rollback fires correctly.
  • Hypothesis ladder for the "remove" action that didn't take:
    1. 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.
    2. The rep deleted from Documenso UI directly (not the CRM), and confused that with a CRM-side remove. The CRM still has the row.
    3. 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):
    1. Persist documensoId IMMEDIATELY after documensoCreate, not at the end. Move the UPDATE documents SET documensoId=... call to right after documensoCreate succeeds (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.
    2. 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 (cancelDocument for active flows, deleteDocument for 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.
    3. 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.
    4. 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.
  • 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:
    1. 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.
    2. 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.
    3. 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?
  • 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..." /> in SigningProgress in ActiveEoiCard in InterestEoiTab.
  • 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 signingUrl was 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.") when signingUrl is 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 from ghost to outline so 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 388393) 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 (c4450dd lineage). 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> in Label in FieldSidePanel at upload-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 recipients prop with #order Name/Email formatted), so the wiring is fine — the gap is purely the label + a missing explainer.
  • Fix applied:
    1. Label text changed from RecipientAssign this field to. Active verb makes it clear this is a deliberate choice the rep is making, not a metadata read-out.
    2. 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.
  • 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/Email formatting.

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 E1 followed 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

  • OPENsrc/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) AND Open (recommender tier). The tooltip definition contradicts itself: "Open: never had an interest, ready for new prospects."
  • Root cause: classifyTier only reads from interest_berths aggregates (active count / lost count / max active stage). A berth whose berths.status column says Under 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 berthStatus to TierInputs and bias classifyTier:
    • 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' AND activeInterestCount === 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.
  • 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: ~3045 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

  • OPENsrc/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-interests endpoint (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)
    • BerthRecommenderPanel recommendation 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.tsx active-interests popover (already exists; keep its richer multi-row popover, but reuse the data fetcher).
  • Effort: ~1.52h. 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 when sourceId is available (clients/companies/yachts/interests/residential variants all covered). New sourceLinkFor(portSlug, source, sourceId) helper centralises the URL mapping. stopPropagation keeps any outer row-click handler from interfering.

Notes tab header count doesn't aggregate

  • OPENsrc/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_notes rows. 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. ~12h 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) + companion isTenanciesModuleEnabled + the tenancies_module_enabled setting) + 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 behind admin.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 with action: 'update' and no fieldChanged.
  • 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: transferOwnership now resolves both the old + new owner names (client → fullName / company → name), writes the audit row with action: 'transfer', fieldChanged: 'owner', oldValue: oldOwnerName, newValue: newOwnerName, plus reason/notes in metadata. EntityActivityFeed's ACTION_VERBS gains transfer → '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 no fieldChanged (or with an object as newValue) 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

  • OPENsrc/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 EntityActivityFeed IS 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 when q was empty; the picker fires its first query with debounced='', so the list was always empty on first open.
  • Fix applied: empty q now returns the 10 most-recently-updated companies for the port (still capped to 10, matching the typed-search path). Non-empty q keeps 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.getById filters activeTenancies to status === 'active' only. Pending rows fall outside that filter and never reach the tab.
  • Fix applied: filter widened to inArray(status, ['pending', 'active']). The TenancyList component 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 · B etc. The mooring number is already prefixed with the area letter, and the dropdown groups options under area-letter headings. The trailing · A reads as visual noise.
  • Fix applied: dropped the area suffix from labelFor — rows now read Berth 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

  • OPENsrc/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 ClientInterestRow with tags?: Array<{ id, name, color }> and surface from useClientInterests (/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 (currently client-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

  • OPENsrc/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).
  • OPENsrc/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

  • OPENsrc/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, so BrandedAuthShell falls back to neutral.
  • Fix: extend loadByToken to also return branding: { logoUrl, backgroundUrl, appName } resolved via getPortBrandingConfig(token.portId); the API surfaces it; the page passes it to BrandedAuthShell via the explicit branding prop.

Supplemental-info form — extends edge-to-edge on long forms

  • OPENsrc/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-0 shell.

Supplemental-info form — address fields incomplete

  • OPENsrc/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_addresses table 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

  • OPENsrc/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

  • OPENsrc/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 ClientChannelEditor which 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 (DocumensoRecipient type extended with rejectionReason + declineReason; DOCUMENT_REJECTED / DOCUMENT_DECLINED handler now coalesces the two field names and passes through).
    • src/lib/services/documents.service.ts (handleDocumentRejected signature gains rejectionReason?: string | null; document_events.eventData stores 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).
  • 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 (rejectionReason on v2; some 1.x payloads use the legacy declineReason). Up to this fix we were ignoring both. Now coalesced + persisted + surfaced.
  • Where the reason now appears (after this fix):
    1. document_events row → eventData.rejectionReason (the audit timeline can render it).
    2. audit_logs row → metadata.rejectionReason (admin's audit-log viewer surfaces it).
    3. 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_events row of type=rejected for the active EOI and pluck eventData.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-poll BullMQ job runs every 5 minutes via src/lib/queue/scheduler.ts:21 and 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 coalesces rejectionReason ?? declineReason and surfaces it on every poller / direct-fetch consumer.
    • src/lib/services/documenso-client.ts:213 (DocumensoDocument.recipients[]) — gains optional rejectionReason?: string.
    • src/jobs/processors/documenso-poll.ts — new else if branch for remoteDoc.status === 'REJECTED' | 'DECLINED'. Finds the rejecting recipient, plucks the reason, hands off to handleDocumentRejected with the same shape the webhook receiver uses — so document_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) — accepts rejectionReason?: string | null, stores on document_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.
  • Result of this fix: even with a broken tunnel, the rejected document will converge to status='rejected' within 5 minutes of the next signature-poll job 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):
    1. Auto-update Documenso's webhook URL on tunnel restart. ./scripts/tunnel-url.sh --copy already 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.
    2. Admin "Webhook health" page. New page at /admin/integrations/webhooks that 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. ~34h.
    3. 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.
    4. Document the "re-paste tunnel URL into Documenso after every tunnel restart" gotcha in CLAUDE.md until the auto-PATCH lands. ~5 min.
  • 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 getDocument call 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.
  • 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:
    1. 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.
    2. 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.

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_order setting overrides the template's stored signing order — but only when the port setting is explicitly set. Mechanism:
    • /template/use creates the envelope from the template. Documenso v2's template-use endpoint silently drops the meta field 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/update while 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's documenso_signing_order setting 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_order setting is set to PARALLEL (overriding the template). Check at Admin → Documenso → Behavior → signing order. Either flip it to SEQUENTIAL (forces sequential regardless of template) or clear it to null (defers to whatever the template specifies, which would honour your SEQUENTIAL template).
  • Suggested UX fix (capture as OPEN): the admin settings form for documenso_signing_order should offer three values, not two: SEQUENTIAL, PARALLEL, and Use 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 need setSigningOrder('SEQUENTIAL') mid-flight if the doc wasn't created sequential).
    • src/app/api/webhooks/documenso/route.tshandleRecipientSigned (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.tsx for the all-done broadcast with signed PDF attached (already 80% there — compose-completion-email route exists per document-detail.tsx:217).
  • React-grab anchor: <section class="rounded-xl bord..." /> in ActiveEoiCard in InterestEoiTab.
  • 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):
    1. 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 has documents.send permission. Same conditions as the existing per-row "Send invitation" CTA but operates over the whole flow.
    2. On click: the dialog branches based on the document's signing order (which the CRM reads from the envelope via getDocument or persists locally on documents.signing_order at 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 each recipient_signed (logic below).
    3. Webhook side (sequential mode only): in handleRecipientSigned, after the existing row update, check the parent doc's automation_mode. If sequential_auto AND there's a next-in-order signer with invitedAt=NULL AND 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.
    4. On completion (handleDocumentCompleted) — shared across both modes: if automation_mode is concurrent_auto OR sequential_auto, queue the existing composeCompletionEmail route logic to send the signed PDF to every recipient (signers + CCs + approvers). Stays decoupled from the user-driven email-completion flow that already exists for manual mode.
    5. 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').
    6. 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.
  • 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: ~68h 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_order per-port setting (already exists per CLAUDE.md Documenso section).
    • compose-completion-email route (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:
    1. 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=NULL and fire from there. Cleanest UX, matches what reps would expect.
    2. 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.
    3. 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.
    4. 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.

/documents/new CreateDocumentWizard — confusing, redundant pathways

  • OPENsrc/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:

  1. Wizard duplicates the dropdown. NewDocumentMenu already 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).
  2. The "inapp" template pathway is undocumented and probably unused. The wizard's pathway dropdown offers documenso-template (rendered by Documenso) vs inapp (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.
  3. 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/new can't even ask for it.
  4. 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 UploadForSigningDialog which is already the right surface. The wizard becomes generation-only.
  • Delete the pathway dropdown. inapp is dead; either remove it or surface it as an admin-only override. Default to documenso-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/new as 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: ~68h 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 inapp pathway 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

  • OPENsrc/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

  • OPENsrc/components/documents/create-document-wizard.tsx:328-370 (the grid 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 subjectType and subjectId in one click.
  • Bundle with: the larger wizard refactor (above) — if /documents/new becomes 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="..." /> in Component chain).
    • 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.