Files
pn-new-crm/docs/MANUAL-TESTING-BACKLOG-2026-05-15.md
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00

88 KiB
Raw Blame History

Manual-testing backlog — 2026-05-15

Source: live walkthrough of the CRM by Matt while testing the Documenso integration end-to-end on port-amador. Each item here was either noted mid-stream or surfaced during testing and is queued for a future focused pass. Items already shipped during the same session are listed in the "Reference: shipped this session" appendix for context.

Format per item: what / where / desired state / effort / notes. Use as a punch list — work top-to-bottom or cherry-pick by area.


0 · Blocked — needs Matt's decision before pickup

Two items can't be picked up until a design call lands. Surfaced here so they don't get buried under implementation chatter.

0.1 Reminders data model — lightweight columns vs richer reminders table

  • Cross-reference: §3.2 below.
  • The choice: Today every interest carries reminderEnabled: boolean + reminderDays: integer directly on the row. A separate reminders table already exists (see operations.ts) with richer fields — title, note, priority, assignedTo, dueAt (timestamp, not days-from-now), snoozedUntil, googleCalendarEventId, etc.
  • Path A — extend the lightweight columns on interests: migration adds reminderNote, reminderTimeOfDay, optionally reminderPriority and recurrence flags. Stays single-row, every interest gets exactly one reminder hook.
  • Path B — push richer reminders into the reminders table: leave reminders_enabled/reminders_days as the simple per-interest hook (one follow-up tick), use the rich table for everything else (dated tasks, assigned reminders to specific reps, recurring nudges, etc.). Already partially wired — searchReminders queries it, RemindersInbox likely renders from it.
  • Why this is blocked: path A is faster but creates a parallel data model for a thing that already has a richer home. Path B is the right shape but requires a UI for "create a task on this interest" that doesn't exist yet, and a clear answer to "what does the existing per-interest reminder do once the rich path exists?".
  • What I need from Matt: which path, and (if B) does the per-interest cadence stay or get retired?

0.2 Supplemental info form — CRM-hosted vs marketing-site

  • Cross-reference: §8.1 below.
  • The unknown: Clicking "Send supplemental info form" emails the client a one-time link. That link's /supplemental/<token> route — does it resolve to a CRM-hosted page (works out of the box) or to the marketing site (which may not have the route deployed yet)?
  • Why this is blocked: every other answer downstream depends on it. If CRM-hosted, this just needs a UX polish pass (§8.2 dual-mode + padding). If marketing-site-hosted, the marketing repo needs the page shipped before any testing makes sense, AND we need to confirm the marketing repo's deploy story for non-Port-Nimara ports.
  • What I need from Matt: a green-light to spend ~15 minutes tracing the route end-to-end. He flagged this as "save for end of pass" so I haven't touched it; capturing here so it doesn't get forgotten.

1 · Interest detail — Overview tab

1.1 Interest timeline detail

  • Where: src/components/interests/interest-timeline.tsx, Activity tab on interest detail page.
  • Current: Shows only "Interest created" event. Every subsequent PATCH (berth linked, desired-dims updated, stage moved, qualification confirmed, etc.) is swallowed.
  • Desired: One row per audited mutation — labelled by entity/action — so reps see the full life of the deal without leaving the page. Don't have to show the raw before/after values, just the fact of the change.
  • Effort: Medium. Audit-log rows exist server-side already (audit_logs for entityType IN ('interest','interest_berths','interest_qualifications', …)). Timeline query is filtering too aggressively or missing entity types.
  • Files: interest-timeline.tsx, possibly a service helper that builds the activity stream.

1.2 Assigned-to default + permission granularity

  • Where: New-interest creation flow + AssignedToChip on detail page.
  • Current: New interests default to "Unassigned"; any user with the right permission can be assigned to.
  • Desired:
    • Default the assignee to the creating user when that user has the "can-be-assigned-to-sales" permission.
    • Add a fine-grained per-user toggle so admins / directors / etc. who normally aren't sales-facing can be flipped on as assignable when they're standing in on a deal.
    • Broader principle: every permission should be tunable per-user, not only via role assignment. Already partially supported via userPermissionOverrides — make sure "is assignable to sales" surfaces in that override UI.
  • Effort: Medium. Permission-key addition, default-on-create branch in interests.service.ts, surface in the user-edit drawer's Permissions tab.
  • Files: interests.service.ts, roles / users admin pages, permissions schema.

1.X Interest Overview — inline-edit Email + Phone on Contact section

  • Where: "Contact" section of the OverviewTab in src/components/interests/interest-tabs.tsx.
  • Current state: Email + Phone surface as read-only echoes of the client's primary email/phone (clientPrimaryEmail / clientPrimaryPhone from getInterestById). To edit them today the rep has to jump to the client page.
  • Desired: Click-to-edit inline using the canonical client-page components — InlineEditableField for email, <PhoneInput> (with the country-flag picker + E.164 normalization) for phone.
  • Wiring needed:
    • getInterestById returns the contactId (client_contacts.id) for the primary email + primary phone row, plus the existing value and valueE164.
    • PATCH /api/v1/clients/{clientId}/contacts/{contactId} handler that updates value + valueE164 (phone) or value (email) and re-asserts the primary flag.
    • On save: invalidate every cache that surfaces this data — clients/{id}, interests/{id}, interests/{id}/eoi-context, and any list endpoint that materializes the email/phone in the row (e.g. client-list, search results, dedup-candidates panel). The "data on the interest record must reflect everywhere it's used" invariant is real here.
  • Effort: Small-medium (~45 min). Service-side return-shape change + new PATCH route + 2 inline-edit components + the cache-invalidation list.

1.3 Payments section: stage-aware

  • Where: OverviewTab.
  • Status: Partially shipped — Payments now hidden before reservation stage.
  • Outstanding: When hidden, should we show a stage-specific "what to do next" card instead? Currently the real estate is just empty until the milestone card takes over. Consider an explicit "Next step" card with shortcuts (Send EOI, Generate Reservation, Record deposit, etc.) per stage.

1.4 Quick "Log contact" button

  • Where: Add to Overview tab on interest detail and client detail.
  • Current: Reps have to navigate to the Contact Log tab and click Compose.
  • Desired: A small button (probably next to "Email / Call / WhatsApp" pills) that opens the compose drawer pre-populated. On the client variant, allow the rep to optionally attach the new contact log to one of the client's interests for better organization.
  • Effort: Small-medium. Just a button + open the existing compose dialog with a defaultInterestId prop.

2 · Contact-log compose

2.1 Convert modal → side drawer

  • Where: src/components/contact-log/compose-dialog.tsx.
  • Current: Centered modal. Cramped when adding a follow-up reminder + body.
  • Desired: Convert to <Sheet side="right"> like the CRM's other form surfaces. More room for the body, reminder, attachments, etc. Matches the Sheet vs Drawer doctrine in CLAUDE.md.
  • Effort: Small (component swap + a few class tweaks).

2.2 Contact-log bells & whistles

  • Voice memo upload + transcription (OpenAI Whisper or local).
  • Attach a follow-up to a specific interest (covered by 1.4 on the client variant).
  • Multi-attachment support for screenshots / docs received during the call.
  • "Outcome" picker (positive / neutral / negative / blocked) for funnel analytics.
  • Templated quick-snippets ("Left voicemail", "Scheduled walkthrough", etc.).
  • Editable-after-save with audit-log trail (currently contact logs are immutable per most CRM patterns — confirm desired behaviour).

3 · Reminders

3.1 Standardize across surfaces

  • Where: InterestForm, contact-log compose, anywhere else reminderEnabled + reminderDays are settable.
  • Current: Each surface has its own copy of the cadence picker (ReminderDaysInput). The component is shared but each surface configures it independently.
  • Desired: A single per-port reminder_presets setting registry that drives the cadence chip options + the default selected cadence across every reminder surface. Admin sets the port's defaults once; every rep-facing surface inherits.
  • Effort: Medium. New registry entry, new useReminderPresets() hook, replace hardcoded PRESETS array in ReminderDaysInput.

3.2 Reminder customization (richer fields)

  • Where: interests.reminderEnabled + reminderDays columns.
  • Current: Toggle + integer days. No note, no priority, no time-of-day, no recurrence.
  • Desired (optional schema migration):
    • reminderNote text — what the reminder is about
    • reminderTimeOfDay text — HH:MM in port timezone
    • reminderPriority text — low/medium/high (mirrors reminders table)
    • reminderRecurring boolean + reminderRecurringDays integer
  • Alternative: Keep the lightweight model on interests and push richer reminders into the existing reminders table (already has title/note/priority/ assignedTo/snoozedUntil — see operations.ts).
  • Decision needed: which model? Lightweight stays as-is and the rich table is for explicit dated tasks. Pending Matt's call. See also the per-EOI reminder control work in 4.7 which covers reminder cadence at the document + per-signer granularity.

4 · EOI generation

4.1 Full inline editing in the Generate-EOI drawer

  • Where: src/components/documents/eoi-generate-dialog.tsx.
  • Status: Partially shipped:
    • Dialog → Sheet conversion
    • Inline fix-it form for MISSING name/email/address (uses Input + CountryCombobox; persists via clients PATCH + addresses/contacts POST)
    • Inline-edit pencil for name/nationality/yacht-name already exists on PreviewRow
  • Outstanding:
    • Email — needs an inline-edit row that PATCHes the matching client_contacts.value row. Requires surfacing contactId in eoi-context.ts response (currently flat primaryEmail only) + a PATCH /api/v1/clients/{id}/contacts/{contactId} wrapper.
    • Phone — same, plus needs the <PhoneInput> component for formatting (don't show raw E.164 + flag as a plain text field). The data path mirrors email.
    • Address — multi-field (street/city/country). Needs addressId in the context payload, then an inline sub-form (3 inputs + CountryCombobox) that PATCHes /addresses/{addressId}.
    • Yacht dimensions — Length/Width/Draft should be editable inline with the same ft↔m auto-convert as YachtForm. Persists via PATCH /yachts/{id}.
  • Effort: Medium-high (~1-2h focused). Server-side change to enrich eoi-context.ts with row IDs, new PATCH wrappers for contacts, multi-field editor component for address.

4.2 EOI-scoped data overrides (don't touch the canonical record)

  • Where: EOI Generate drawer.
  • Use case: Sales wants to render an EOI with values different from the client/interest record without overwriting the canonical record. Example: the client's billing address is in London (primary on the record), but for this specific EOI they want to use a secondary Monaco address. Today, editing in the drawer PATCHes the canonical record — there's no way to "use this value for this EOI only".
  • Desired: A per-field "use this only for the EOI" toggle (default off). When on:
    • The field value goes into the generated EOI document
    • The canonical client/interest record stays untouched
    • The override value is persisted as a secondary record with a flag (is_eoi_only: true or similar), tagged with a note like "Captured for EOI #{externalId} on {date}", so it's auditable + recoverable but doesn't leak into the sales-process surfaces (e.g. doesn't become a candidate for "primary address", doesn't show up in the dedup picker as the address of record).
  • Data model implications:
    • client_addresses and client_contacts already support is_primary. Could add an is_eoi_only boolean (or a more general scope text'primary' | 'eoi-only' | 'archived') to mark these rows.
    • The eoi-context resolver would need to know to prefer the EOI-only row over the primary when an active EOI override exists; behave normally otherwise.
    • Yacht / berth dim overrides could be modelled via a sibling eoi_overrides JSONB on the interest row, since yachts aren't keyed for multi-instance-per-scope.
  • UX: Each editable row in the drawer gets a small checkbox below the input: "Use this only on this EOI (don't change the client record)". Default unchecked = the current behavior (PATCH record). Checked = persists as EOI-only scoped.
  • Effort: High. Schema change + resolver branch + UI toggle + audit-log story. Worth scoping carefully — easy to introduce subtle "which value won?" bugs in downstream surfaces.
  • Open questions:
    • Does the override apply only to this specific EOI document, or to ALL future EOIs for this interest? (Lean: this EOI only, by storing the document_id reference on the override row.)
    • When the rep reopens the Generate drawer after an EOI was previously sent with an override, do we show the original override values or fall back to the canonical record? (Lean: fall back to canonical; force the rep to re-tick if they want the override again.)
    • Are these overrides reusable for related docs (reservation, contract) or EOI-only?
  • Companion: interest-level data overrides (broader scope). Beyond the EOI-only override above, Matt wants a "set contact/address details for just this interest" toggle on the interest record itself. When flipped, the email/phone/address entered overrides what's on the client record for this interest only — and wherever that interest's contact data is shown elsewhere in the app (search results, dedup panel, EOI preview, contact log), the surfaced value is tagged with a small "interest-only" badge so reps understand they're seeing a deal-scoped override, not the client's canonical info. Same data-model shape as the EOI-only case but the is_interest_only flag would be keyed on interest_id instead of document_id. Shares the resolver-precedence model with the EOI flag — interest-only > client-primary at lookup time when the caller is in that interest's scope; client-primary everywhere else.

4.3 EOI Address field — composition control + overflow safety

  • Where: formatAddress() in src/lib/services/documenso-payload.ts + src/lib/pdf/fill-eoi-form.ts, EOI source PDF AcroForm Address field.
  • Now shipping (this session): the EOI Address field renders as street, city, REGION, postal, COUNTRY where REGION is the ISO-3166-2 suffix (e.g. NY) and COUNTRY is the alpha-2 ISO code (e.g. US). Inline fix-it form on the generate drawer now accepts street + city + region + postal + country. The standalone Nationality PDF field has been retired — the resident's country lives on the Address line.
  • Still open / deferred:
    • Admin setting per-port to choose which address pieces are included on the EOI Address line (e.g. allow ports that want to drop the subdivision because their clients are mostly EU where XX-XX codes aren't recognised). Backed by a system_setting like eoi_address_components: ['street','city','subdivision','postal','countryIso']. Default to all five.
    • Dynamic font sizing inside the AcroForm box — pdf-lib supports field.setFontSize(...); need to measure the rendered string width against the field's available width and step the size down (e.g. 11pt → 9pt → 8pt) until it fits. Currently the PDF's Address field has a fixed font size set in the template, so a too-long address line will truncate or overflow.
    • Preview check on the generate drawer that shows a warning when the projected line exceeds a known character threshold so the rep can shorten before signing (e.g. swap the formal street name for an abbreviation).
  • Why: Test cases like 108 Avenue du Trois Septembre, Cap d'Ail, , 98000, FR already push the box; longer EU/Asian addresses overflow.
  • Effort: Medium. Admin setting is straightforward registry entry + resolver call. Font auto-fit needs measurement helper + PDF-pass change.

4.4 Bypass-with-warning button

  • Status: Intentionally not shipped — Matt mentioned it then accepted the reasoning (EOI's top legal paragraph requires the missing fields; bypass produces unsignable docs). Note here in case the requirement returns.
  • If revisited: Add a skipValidation: true flag on the generate endpoint, surface a confirm modal in the drawer with explicit "this EOI will have blank legal fields" warning.

4.5 Documenso recipients — CRM as source of truth for all 3 signers

  • Where: buildDocumensoPayload in src/lib/services/documenso-payload.ts, per-port settings in src/lib/settings/registry.ts.
  • Status: Next up — actively being shipped. Matt's call: changes to a signer (e.g. David Mizrahi leaves, replaced by someone else) should happen ONLY in our CRM admin settings — never in the Documenso template UI.
  • Documenso v2 behaviour confirmed via OpenAPI + docs:
    • Template recipients can be saved with placeholder values (e.g. developer@placeholder.crm · Developer (placeholder)) — Documenso lets you save the template that way. The "Use template" UI gate (and the /api/v2/template/use endpoint) requires every slot to have a valid email/name though, so placeholders must be present at use-time.
    • At /api/v2/template/use time, the recipients array maps slot id{email, name}. Provided values win for that document only — the template's stored values are untouched. Slots omitted from the array fall through to the template's stored values.
  • Current state of the code: buildDocumensoPayload passes email: '', name: '' for developer/approver so template values win. That's the OPPOSITE of what Matt wants — and it relies on v2 silently treating '' as fall-through, which the spec doesn't actually guarantee.
  • Target state: Resolve real email + name for all 3 slots from per-port CRM settings:
    • Client slot — already populated from the EOI context (no change).
    • Developer + Approver slot — resolve in this order:
      1. If documenso_<role>_user_id is set → look up that user's userProfiles.displayName + primary email (joined from user table).
      2. Otherwise → fall back to two new free-text registry settings documenso_<role>_email + documenso_<role>_name so ports without a linked CRM user can still pin a static value.
    • Pass these as real values in the recipients array. Template placeholders are then invisible in practice — they exist only to satisfy v2's UI gate.
  • Setup Matt needs to do once per port before shipping:
    • On the Documenso v2 template, add 3 recipient rows with placeholder values:
      • client@placeholder.crm · CRM Client (placeholder) · SIGNER
      • developer@placeholder.crm · Developer (placeholder) · SIGNER
      • approver@placeholder.crm · Approver (placeholder) · APPROVER
    • Enable signing order on the template, ordered Client → Developer → Approver.
    • Save template. Run "Sync from Documenso" in the CRM admin.
    • Fill in the per-port linked-CRM-user dropdowns (developer + approver).
  • Effort: ~30 min — registry entries + resolver helper that joins to user/userProfiles + buildDocumensoPayload change + tests.

4.6 EOI dimensions — unit toggle + per-field emission

  • Where: "Dimensions (L × W × D, ft)" preview row in src/components/documents/eoi-generate-dialog.tsx + buildDocumensoPayload / fill-eoi-form Length / Width / Draft formValues.
  • Current state:
    • The drawer's preview row hardcodes "ft" in the label and renders [lengthFt, widthFt, draftFt] joined with ×.
    • buildDocumensoPayload and fill-eoi-form already emit Length, Width, Draft as separate formValues — so per-field send is good.
    • However the values passed are always in feet regardless of which unit the rep originally entered on the yacht.
  • Desired:
    • Unit toggle (ft / m) at the top of the dimensions row in the EOI drawer.
    • Default to whichever unit the rep entered on the yacht record. The yacht row stores both lengthFt/widthFt/draftFt AND lengthM/widthM/draftM — we need a way to know which was the "source of entry". Either:
      1. Add a dimensions_unit_source: 'ft' | 'm' | null column on yachts that the YachtForm sets when the rep types into either input. Or
      2. Heuristic: if lengthM is set but lengthFt is null → m; vice versa. (Brittle when both are saved.)
    • The selected unit's value flows into Length/Width/Draft formValues so the rendered EOI matches what the rep entered.
  • Effort: Small-medium (~45 min). Toggle component + dimension formatter + schema column (if going with option 1) + buildDocumensoPayload swap.

4.10b Document detail page — full refactor

  • Where: /[portSlug]/documents/[id]src/components/documents/document-detail.tsx (~407 lines).
  • Symptom: The page has been there since pre-EOI work but never caught up to the polish we shipped on the EOI tab. Multiple problems compound: confusing "Watchers" section with no Add button, no way to send invitations from this page (only "Remind" buttons that are ambiguous), Linked Entity row shows a bare "Interest →" with no name, Activity panel always says "No events yet" even when the document has dozens of events in document_events.
  • The full refactor — 6 deliverables (in priority order):
    1. State-aware action button per signer, matching the EOI tab's just-shipped pattern:
      • invitedAt === null → primary "Send invitation" button (paper-plane icon, fires the same send-invitation route the EOI tab uses, which now handles v2 distribute-or-self-heal).
      • invitedAt !== null && status === 'pending' → outline "Send reminder" button (bell icon).
      • status === 'signed' → no button, signed-when timestamp.
      • status === 'declined' → no button, rose tint card, "Declined on {date}" line. Surfaces the rejection.
    2. Visual parity with <SigningProgress> from the EOI tab. Avatar circle with cleaned initials (strip (was: ...) / (placeholder) suffixes), status-icon overlay, color-tinted card per status (pending neutral, opened sky, signed emerald, declined rose), left accent stripe, activity timestamps inline. Promote the existing <SigningProgress> component into a shared element this page also uses — same data shape.
    3. Linked Entity row — clickable name + entity-typed label. Resolve the polymorphic FK chain on documents (interestId, clientId, yachtId, companyId, reservationId) into a tile showing the entity TYPE + NAME with a <Link> to its detail page. e.g. "Interest — Matt Ciaccio (Berth A2)" linking to /interests/<id>. Multiple linked entities show as a chip row.
    4. Watchers section — copy + Add UI.
      • Heading subtitle: "Watchers get an in-app notification on every signing event (opened, signed, declined, completed)."
      • "Add watcher" combobox picking from CRM users in the port.
      • Existing watcher rows get a delete (×) button.
      • Backend: routes already exist (/api/v1/documents/{id}/watchers POST + DELETE).
    5. Activity panel — read from document_events.
      • Reverse-chrono list of every event for this document: created, sent, viewed (by signer name + time), reminder sent (to whom + when), signed (by whom + when), rejected (by whom + when + reason if Documenso passed one), completed, voided, deleted.
      • Each row: small icon per event type, actor name + relative time + precise tooltip. Mirror the audit-log row pattern.
      • Empty state only renders when there are genuinely zero rows (not the current "default to empty even when rows exist" behaviour — confirm the read path on getDocumentDetail actually returns events).
    6. Cleanup the leaked (was: <email>) suffix on signer name displays — same cleanSignerName helper we shipped on SigningProgress applies here. Currently rows render Matt Ciaccio (was: matt@letsbe.solutions) which is the EMAIL_REDIRECT_TO redirect leaking through.
  • Effort: Medium-high (~4-6h). Most data is already in DB + routes; this is mostly a UI rebuild + activity-feed read-path fix
    • adopting the shared SigningProgress component.
  • Wires nicely with §9.Y2 (the dev-mode EMAIL_REDIRECT_TO badge initiative) — this page is one of the surfaces that needs the per-row redirect badge when the var is on.

4.10 Delete EOI from history — UI on top of the shipped soft-delete backend

  • Where: EOI history list section of interest-eoi-tab.tsx (the cancelled / past-EOIs strip) + the document list page.
  • Backend status: ALREADY SHIPPED this session. deleteDocument was changed from hard-delete to soft-delete: sets status='deleted', inserts a documentEvents row with eventType='deleted', calls documensoVoid on the linked envelope (best-effort), and audit-logs the action with old/new status. Refuses to fire while signing is in-progress (sent / partially_signed) — rep has to cancel first.
  • What's missing — frontend:
    1. Per-row Delete button on cancelled / expired / completed-with- no-file rows in the EOI history list. Confirms first with a concise modal: "Delete this EOI? The Documenso envelope will be voided and removed from upstream; the audit log keeps a record. This can't be undone." (Stronger copy than cancel because the surface implies permanence even though the docs row stays.)
    2. Filter the primary EOI list to exclude status='deleted' rows so deleted entries don't clutter the timeline.
    3. Surface deleted rows under a "Deleted" filter chip alongside the existing status chips so the rep can browse history.
    4. Server-side filter checkgetDocumentsForInterest / listDocuments need a includeDeleted: boolean knob (defaults to false). Without this the "Deleted" filter has nothing to query.
    5. Audit-log surface: deleted docs show up in the interest's activity timeline as "Deleted by {user} on {date}" — confirm this is wired (createAuditLog is fired by the service; check the timeline component reads action='delete' on entityType='document').
  • Effort: Small-medium (~45 min). Pure frontend + one server-side filter knob on the list endpoints.

4.12 v2 envelope title — debug why update doesn't stick in Documenso UI

  • Where: src/lib/services/documenso-client.tsdocumensoGenerateFromTemplate v2 branch. The update path is wired:
    POST /api/v2/envelope/update
    body { envelopeId, data: { title } }
    
    Per Documenso v2 docs that's the correct shape. Title field accepts a string while envelope is in DRAFT (ours is, we update before distribute).
  • Symptom: Documenso's "Documents" list keeps rendering the template's underlying PDF filename (Port Nimara-Berth-EOI-NDA_October2025_FINAL.pdf) instead of our intended title (Matt Ciaccio-EOI-NDA-A2). Persists after multiple fresh generates with the corrected endpoint shape.
  • Hypotheses (ordered most → least likely):
    1. Documenso UI displays PDF filename even when envelope.title is set. The list view's "Title" column may prefer the underlying PDF name as a fallback. To rule out: check the envelope detail view (signatures.letsbe.solutions/t/.../documents/envelope_xxx) — if the detail header shows Matt Ciaccio-EOI-NDA-A2, the API is working and only the list UI is misleading.
    2. Update call returns 200 with {success: false} silently. Our documensoFetch only throws on non-2xx HTTP. The verification log line shows what the API actually persisted vs what we sent. titleMatches: false here would mean the update is being accepted-but-not-applied (likely a v2 schema validation that drops unknown / malformed fields without erroring).
    3. Field name mismatch. Maybe v2 internally stores data.name not data.title, and the docs are stale. Could try data.title AND data.name in the same body and see if either takes.
    4. Template-bound titles. v2 might enforce that envelopes created via /template/use inherit and lock the template's title — and envelope/update is for non-template envelopes (made via /envelope/create with a fresh PDF). Workaround in that case: rename the underlying PDF before uploading to the template, OR use /envelope/create instead of /template/use so we control the source PDF filename per-document.
    5. Auth header on update call. Docs show Authorization: api_xxx but our documensoFetch always prefixes Bearer. The Bearer prefix works for /template/use and /envelope/distribute, so this is unlikely — but worth checking the response if hypothesis 2 looks accepted-but-coerced.
  • Debug plan (pair w/ Matt):
    1. Tail Next.js dev server console (pnpm dev terminal).
    2. Generate one fresh EOI from the Overview tab.
    3. Capture the two new log lines:
      • Documenso envelope title update — response
      • Documenso envelope title update — verification
    4. Decision tree:
      • titleMatches: true → hypothesis 1 wins. Open the envelope detail URL in Documenso, confirm title is right there, file a Documenso UI bug / decide if we care.
      • titleMatches: false → hypothesis 2/3. Inspect raw updateResponse body for any validation errors. Try the dual-field POST (data: { title, name }) as the next test.
      • No log lines printed → the update call isn't firing at all. Check the order of operations in the v2 branch + verify desiredTitle resolves to a non-empty string at runtime.

4.14 Deal pulse + sales process: missing signals, oscillation risk, Regenerate flow

  • Where: computeDealHealth in src/lib/services/deal-health.ts, pipeline-stage auto-advance (advanceStageIfBehind in interests.service.ts), EOI cancel flow in documents.service.ts, EOI tab UI.
  • Three coupled changes from Matt's design question:
    1. Add positive "EOI sent" signal to deal pulse. Today computeDealHealth only has the NEGATIVE signal "EOI awaiting signature for >14d → -10" (lines 165-178). No +X for the moment the EOI is dispatched. Add: when dateEoiSent is set AND eoiDocStatus IN ('sent', 'partially_signed') AND the doc is NOT cancelled/rejected/deleted AND <14d since send → +10. The existing -10 trips automatically once we cross the 14d threshold.
    2. DECISION NEEDED — auto-advance pipelineStage on EOI generate. Currently generating an EOI doesn't move the stage from qualifiedeoi. Auto-advance via advanceStageIfBehind would give an additional +10 to the pulse via stage_progress AND make the kanban / pipeline view reflect what's actually happening. Risk: introduces stage oscillation if EOI is cancelled later (no auto-rollback). Mitigated by the Regenerate flow below — but only for the typo-fix path; full cancels would still leave the stage at eoi until the rep moves it manually. Matt's call needed: ship the auto-advance or keep stage moves explicit?
    3. Replace cancel+regenerate with a single "Regenerate" button. UX win + oscillation defuser:
      • Single button on the active EOI card, next to "Cancel EOI".
      • Pre-invite path (invitedAt === null on every signer): silent in-place replace. Wraps cancel + generate-new in one transaction so interest.eoiDocStatus, dateEoiSent, deal-pulse score, and pipeline stage never dip between the two calls. The Documenso envelope is voided + a fresh one created; the UI just flashes the new EOI in place.
      • Post-invite path (anyone in the chain has been emailed): warning modal listing each signer's email + invitedAt timestamp + a required reason field ("Why are we regenerating? Logged for audit"). Then same in-transaction cancel+generate.
      • Both paths reopen the EOI generate drawer pre-filled with the current details first, so the rep can fix the wrong-data reason before the new envelope mints.
      • Captures the actual workflow — Matt's right that ~all cancels are "I made a typo, let me redo" rather than "kill this deal" (the latter goes through Cancel + don't regenerate).
  • Effort: Medium (~3-4h). New deal-pulse signal + decision-gated stage auto-advance hook in generateAndSignViaDocumensoTemplate + new POST /api/v1/documents/{id}/regenerate route that wraps cancel+generate in a transaction + reopen the EOI generate drawer with prefill + UI button placement.

4.15 Sales-process + deal-pulse trigger audit

  • Why: Matt called out that the automatic raising/lowering triggers for both the pipeline-stage advance flow AND the deal-pulse score aren't surfaced anywhere as a holistic list, and likely have gaps (e.g. the missing positive "EOI sent" signal in §4.14 above is one symptom).
  • Audit scope — list every place that touches each automatic trigger:
    • Pipeline-stage auto-advance (advanceStageIfBehind callers, plus any direct changeInterestStage calls fired by service code rather than the user). Map every call site to the trigger condition + the target stage. Cover: EOI signed-webhook flow, deposit-received auto-advance, contract-signed webhook, reservation-stamped, won/lost outcome flips, manual stage moves.
    • Stage auto-rollback (does any code path move a stage BACKWARD automatically — e.g. when an EOI is cancelled or rejected?). Likely the answer is "no" — confirm in the audit, decide whether that's correct or a gap.
    • Deal-pulse signals (computeDealHealth — every signals.push
      • the conditions guarding it). Document each:
      • active_engagement (+5 if any contact-log entries in last 7d)
      • contact_recent (+20 if dateLastContact <=7d)
      • contact_warm (+10 if <=14d)
      • contact_stale (-15 if >=30d)
      • stage_progress (+10/+20/+30, capped, per pipelineStage index)
      • stuck_top_funnel (-10 if firstDays >=30 + stage in enquiry/qualified)
      • eoi_awaiting (-10 if eoiSentDays >=14 + not signed)
      • deposit_pending (-10 if reservation signed >=21d + no deposit)
      • contract_awaiting (-10 if contract sent >=14d + not signed)
    • Heat tooltip explainer — verify the in-product copy matches the actual computation logic (any drift = confusing).
  • Gaps to flag as candidates for a fix wave:
    • Missing positive signals: EOI sent (§4.14 §1), deposit received, contract signed (the moments of progress should each contribute).
    • Missing negative signals: signer declined / EOI rejected, interest archived-and-unarchived cycle (zombie deals), reservation cancelled, deposit refunded, berth status change to sold-to-other.
    • No "signer engagement" pulse signals — even though Documenso fires RECIPIENT_VIEWED webhooks. A signer who opened but didn't sign in N days is a stalling-signal worth surfacing.
    • Stage auto-rollback policy — currently nothing rolls a stage back on EOI cancel; that may or may not be correct.
    • Reminder cadence on eoi_awaiting — currently a single -10 at 14d. Could escalate to -20 at 21d, -30 at 30d.
  • Output: A short audit doc (docs/deal-pulse-trigger-audit.md) listing every trigger + a punch-list of gaps to address in follow-up commits. Once that's in front of Matt, he picks which gaps to ship vs which to defer.
  • Effort: Small (audit + doc, ~1h). The fixes themselves are scoped per-gap once the audit is in.

4.13 EOI rejection — cascade emails + notifications + UI banner

  • Where: webhook handler handleDocumentRejected in src/lib/services/documents.service.ts (already wired end-to-end at the data layer) + new UI banner on the EOI card + new cascade-email service.
  • What's already wired at the data layer (no change needed here):
    • DOCUMENT_REJECTED / DOCUMENT_DECLINED webhook events are handled by handleDocumentRejected. It flips documentSigners.status = 'declined' for the rejecting recipient, documents.status = 'rejected', interests.eoiStatus = 'rejected', inserts a documentEvents row with eventType: 'rejected', emits document:rejected over the socket bus.
  • What's missing (what to ship):
    1. Role-based cascade email when a signer rejects. Logic per Matt:
      • Client rejects → email developer + approver. The deal is likely dead — internal team needs to know to stop work / close the interest.
      • Developer rejects → email both client and approver. Internal sign-off failed; both sides of the table need to know the deal stops here.
      • Approver rejects → email developer only. Final-stage internal escalation; client doesn't know yet so the developer can attempt to salvage (renegotiate terms, escalate further) before notifying the client.
      • Cascade fires inside handleDocumentRejected via a new sendRejectionCascade(documentId, rejectingRole) helper that reads the doc's signers + the rejecting role + dispatches via the existing sendSigningInvitation-adjacent path with a per-port branded "EOI rejected" template.
    2. In-app notification to:
      • Interest assignee (always, regardless of who rejected)
      • The CRM users linked to the developer + approver slots (documenso_developer_user_id / _approver_user_id if set — fall back to the port admin list if unset).
      • Notification body: who rejected (signer name + role) + reason if Documenso captured one + deep-link to the EOI tab.
    3. UI banner on the EOI card when documents.status = 'rejected':
      • Distinct from the existing CANCELLED state (rose accent stripe on the card top edge + a single-line banner).
      • Reads: "Rejected by {signerName} ({role}) on {date}".
      • If Documenso passes a rejection reason, show it on a second line: "Reason: {reason}".
      • CTAs on the banner: "Reopen this interest (re-negotiate)" + "Archive interest (deal dead)" — both audit-logged.
    4. Per-signer card visual on the SigningProgress component — the rose tint + X icon overlay already exists for declined (was shipped in the signing-progress redesign), so this just naturally surfaces once the webhook fires. No extra UI change.
  • Cascade-email template content needs admin-tunable copy via the registry (new section email.rejection_templates) — three keys: rejection_email_to_internal_subject / rejection_email_to_internal_body (used when developer or approver rejects → emails to client) and rejection_email_internal_escalation_subject / rejection_email_internal_escalation_body (approver-rejects → developer-only escalation). Default copy ships in the migration.
  • Effort: Medium-high (~3-4h). Service helper + 3-4 email templates + UI banner + notification entries + audit log + tests.

4.11 EOI real-time signing-progress tracking — verify wiring end-to-end

  • Where: Documenso webhook handler → documentSigners.status updates → realtime broadcast → SigningProgress re-renders.
  • What's already there:
    • Documenso webhook receiver at /api/webhooks/documenso/route.ts handles RECIPIENT_VIEWED / RECIPIENT_SIGNED / DOCUMENT_SIGNED / DOCUMENT_COMPLETED and updates the matching document_signers row (by token or email).
    • SigningProgress polls /api/v1/documents/{id}/signers every 30s (see useQuery refetchInterval).
  • Gaps to verify (Matt to test):
    • When a signer opens the doc → does openedAt get stamped + does the next poll surface the new "Opened" state (blue tint card + eye icon) within 30s?
    • When a signer signs → does the status flip to "Signed" (emerald card + checkmark) + the EOI card header counter "X of N signed" update on the same poll?
    • Socket-based push (not just poll) — emitToRoom is wired on doc events; check that the interest detail page subscribes to port:{portId} and invalidates the signers query on document:signer_updated so the UI updates within seconds rather than waiting for the 30s tick.
  • If gaps exist: implement socket-subscribe → query invalidate on the interest detail or EOI tab so the SigningProgress card updates in real time.
  • Effort: Verification (15 min Playwright) + small fix if missing (~30 min).

4.9a Embedded signing host — Test button + verified-at gate

  • Architectural rule (Matt 2026-05-15): all outbound signing-invite emails go through our branded sendSigningInvitation template. Documenso never fires its own emails for our envelopes (meta.distributionMethod: 'NONE' enforced at distribute time). The link inside our branded email points to either:
    • The wrapped marketing-site URL ({embeddedSigningHost}/sign/<type>/<token>) when the host is configured AND verified working.
    • The raw Documenso signing URL otherwise.
  • What's wired today: wrapBrandedSigningUrl in document-signing-emails.service.ts checks embeddedSigningHost !== null and either wraps or passes through. No verification gate.
  • What's missing: the "verified" half. Typo or unreachable marketing host = broken email links + no warning to the rep.
  • Ship:
    1. Test connection button on the admin embedded_signing_host field. Click → server-side fetches {host}/sign/__probe__ (or an agreed sentinel path) with a short timeout, expects a known 200/404 shape.
    2. On success, persist embedded_signing_host_verified_at in system_settings.
    3. wrapBrandedSigningUrl checks verifiedAt IS NOT NULL && verifiedAt within last 30d before wrapping; otherwise falls back to raw Documenso URL even if the host is set.
    4. Admin UI badge per state: green "Verified {date}", amber "Verified more than 30d ago", red "Not verified — links fall back to Documenso".
    5. Saving a new host clears verifiedAt so the rep has to re-test after every change.
  • Effort: Medium (~1-2h). New POST endpoint + service helper + registry timestamp column + UI button + branded badge.

4.9b Embedded signing — admin help button with setup instructions

  • Priority: LOWEST. Don't touch until everything else in §4 is shipped.
  • Why: Right now the only way a port admin can stand up a NEW marketing site to host our Documenso embedded signing pages is by pinging us. Knowledge is tribal. Adding an in-product help surface makes it self-serve for future ports / contractors.
  • What to ship:
    • Small ? / "Setup instructions" button next to the embedded_signing_host admin field. Click → opens a side Sheet (right slide-in) with the full how-to.
    • Content of the how-to: a step-by-step that covers everything a fresh marketing-site project needs to wire up the embed:
      1. The /sign/[type]/[token] route (signature host page — iframe wrapper for the Documenso UI).
      2. The runtime config the route reads (useRuntimeConfig().public.documensoHost or equivalent).
      3. Env vars the marketing site needs (Documenso instance URL, any CSP / sandbox flags).
      4. Post-sign redirect page (the URL Documenso sends signers to after they finish — our documenso_redirect_url setting must point at it).
      5. DNS / Cloudflare config for the signing subdomain (signatures.{port-domain} typically).
      6. How to verify end-to-end: generate an EOI, send invitation, open the email, click the wrapped URL, confirm the embed loads + signing flow completes.
  • Source repos to analyse before writing the instructions (so the doc reflects what actually works, not what we think works):
    • THIS CRM repowrapBrandedSigningUrl in document-signing-emails.service.ts, the embedded_signing_host + documenso_redirect_url registry entries, how the /sign/<type>/<token> URL shape is generated.
    • OLD CRM repo — any legacy scripts or docs that already captured this integration (don't reinvent if there's prior art).
    • Port Nimara website repo (/Users/matt/Repos/Port Nimara/Website) — the actual /sign/[type]/[token].vue route that wraps the iframe, the runtime config it reads, env vars it expects.
  • Effort: Small-medium (~1-2h once we commit to doing it). Mostly documentation work + a help Sheet component. No new wiring.
  • Pairs with §4.9a (verified-at gate) — the help instructions should reference the Test button and the verified-at workflow.
  • Where: embedded_signing_host setting + marketing-site /sign/[type]/[token] route.
  • Current: Dev tests use raw Documenso URLs (skipping the wrap layer).
  • Desired for prod-style testing: Patch the marketing site's hardcoded documensoHost = 'https://signatures.portnimara.dev' (line 142 of /Users/matt/Repos/Port Nimara/Website/pages/sign/[type]/[token].vue) to read from useRuntimeConfig().public.documensoHost so the iframe can point at the user's testing Documenso instance.
  • Effort: Small (marketing site repo). Out of scope of CRM repo but needed end-to-end.

5 · Berth recommender

5.1 "Add to interest" surfacing

  • Where: BerthRecommenderPanel rec cards.
  • Current: Add button only appears inside the expanded card body. Reps scrolling the list have to click each card to reveal it.
  • Desired: Quick-add button on the collapsed card row too (small icon button next to the score). Same AddBerthToInterestDialog opens.
  • Effort: Small (just an extra Button in the row header).

5.2 Add-berth dialog: in-EOI-bundle toggle

  • Where: src/components/interests/add-berth-to-interest-dialog.tsx.
  • Current: Radio between "Pitching specifically" (marks "Under Offer" on public map) and "Just exploring" (internal-only).
  • Desired: Third toggle for isInEoiBundle ("genuinely interested — include in the EOI's signed berth range"). Matt mentioned wanting "if they're genuinely interested" alongside the map-marking toggle.
  • Effort: Small. Service already accepts the field; add UI checkbox.

6.1 Verify coverage gaps

  • Status: Search 500 (reminders bug) shipped. Archived clients confirmed hidden from search per Matt's choice. Search-by-email confirmed working (via client_contacts JOIN).
  • Unverified — possible gaps:
    • Client address fragmentsclients.address JSONB isn't queried in searchClients.
    • Yacht hull number / registration — should be in searchYachts; needs a spot-check.
    • Company tax ID / billing address — same.
  • Effort: Small per field (ILIKE predicate in the existing SQL block).

7 · Layout / copy / UX cleanups (small)

7.1 Heat / deal-pulse explainer docs page

  • Where: DealPulseChip popover has a "Full guide" link to /docs/deal-pulse.
  • Current: Route doesn't exist yet. Click 404s.
  • Desired: Static doc page or markdown render explaining the rule-based score in plain English.
  • Effort: Small.

7.2 Stage guidance card

  • Where: Overview tab.
  • Desired: For each pipeline stage, a small "next step" card on the Overview that explains what the rep needs to do to move to the next stage, with shortcut buttons (Send EOI, Generate Reservation, Record deposit, etc.). Replaces the empty Payments slot at non-deposit stages.
  • Effort: Medium (stage-aware component + per-stage copy).

8 · Supplemental info request form

8.1 CRM-hosted vs marketing-site

  • Status: Matt asked whether the supplemental-info button on the EOI-not-ready card creates the form on the CRM or relies on the marketing site. Said save for end of pass.
  • Action when revisited: Trace SupplementalInfoRequestButton → the public form route → confirm whether it's CRM-hosted (good — works out of the box) or marketing-site-hosted (needs the website repo to ship the form). Fix gap if any.

8.2 Conditional render + dual-mode copy

  • Where: SupplementalInfoRequestButton card (the "Need more info before drafting the EOI?" card on the interest Overview).
  • Current: Always renders the same copy ("Need more info before drafting the EOI?"), regardless of whether the client actually has the required fields.
  • Desired (two modes):
    • Missing-data mode (current copy): when the interest's EOI context is failing the required-field check (name / email / address / yacht / berth not set), show the prompt as it currently reads — the rep should send the form to fill the gaps.
    • Confirmation mode (new): when everything required is already on file, swap to a softer prompt — "Send a one-time form to the client to confirm their info?" — for cases where the sales person wants the client to verify the data themselves before the EOI goes out.
  • Plus: Add padding above the header at the top of the card (currently the title sits flush against the parent edge).
  • Effort: Small. Conditional copy branch driven by the same eoi-context check the dialog already uses, plus a pt-6 (or similar) on the CardHeader.

9 · Reference: shipped this session (for context)

Settings / admin:

  • Env→admin migration registry + resolver + form
  • "Save N changes" bulk button on every registry-driven form
  • "Reveal" endpoint for encrypted settings (admin can verify what's saved)
  • Sync result panel persists across refresh (cached documenso_eoi_template_sync_report)
  • Template-level meta (signing order / distribution method / redirect URL) shown after Sync
  • EOI generation + EOI templates card migrated to registry-driven form

Documenso v2 buildout:

  • getTemplate(envelope_id|numeric_id) + template sync endpoint
  • AcroForm inspection: downloads each envelope item's PDF, inspects native fields, diffs against CRM expected EOI label set
  • prefillFields-by-ID emission for v2 instances
  • updateEnvelope v2-only wrapper
  • CC / VIEWER recipient roles + extraRecipients
  • Health check now uses /api/v2/document for v2 (was /api/v1/health which doesn't exist on v2 cloud)
  • Sync misrouting bug fixed (recipient IDs were being written to user-ID keys)
  • User-select dropdown for the developer/approver linked-CRM-user fields
  • Default name/email placeholders ("David Mizrahi" / "Abbie May") removed; blank now passes "" to Documenso which falls through to template's stored values

Forms / UI:

  • Yacht dimensions auto-convert ft↔m (paired inputs, single Dimensions section)
  • Phone input width fixed (CountryCombobox w-24 shrink-0; PhoneInput wrapper w-full)
  • Primary contact per-channel (Primary email + Primary phone, etc.)
  • Yacht picker: dashed-border "Add yacht for this client" prompt when zero yachts
  • Lead Category + Stage auto-fill on berth pick (create mode, manual-touch wins)
  • Tags section hidden when port has zero tags
  • Multi-berth selector on InterestForm (first=primary, extras via /berths POST)
  • Dedup-suggestion panel shows archived state with Restore link
  • "Open" stage copy purged (replaced with "New Enquiry")
  • "no AI" mentions removed from DealPulse popover + heat tooltip copy

Search / data:

  • Search 500 fixed (searchReminders calling .toISOString() on string)
  • All archived_at.toISOString() calls hardened across search.service.ts
  • match-candidates returns archivedAt so dedup panel can render archived state
  • New /api/v1/admin/users/picker for the user-select dropdown
  • Search archived behaviour decided: hide (matches Matt's call)

Interest detail / pipeline:

  • Phase classification rewritten — Overview always surfaces a CURRENT milestone
  • Payments section hidden before reservation stage
  • DealPulseChip popover (click instead of hover, plain-language explainer, "Full guide" link placeholder)
  • EOI MilestoneSection footer with Generate EOI (opens drawer) + Open EOI tab (deep link) buttons
  • EoiGenerateDialog mounted at OverviewTab level (state was wired but component never rendered)
  • Recommendations tab swapped from legacy "AI" RecommendationList to the same rule-based BerthRecommenderPanel used on Overview
  • Recommendations empty-state "Show oversized matches too" button (raises maxOversizePct to 1000 so berths beyond strict tolerance surface)
  • dimensions qualification auto-satisfies on yacht-dims OR desired-berth-dims
  • Area letter dedup, In-EOI-bundle empty-header dedup, Berth Range tooltip spacing fix
  • Recommendations header shows entered unit (ft or m)
  • "Mark EOI as sent" → "Mark EOI as sent manually" (Documenso webhook auto-stamps for normal sends)

EOI generate drawer (in flight):

  • Dialog → Sheet conversion
  • Inline fix-it form for missing name/email/address (uses canonical components, persists via PATCH/POST)
  • Real upstream error message surfaces ("Cannot generate EOI — missing X, Y, Z") instead of generic "preview failed"
  • Address inline fix-it now accepts street, city, region, postal, country (was just street + city + country); persists postalCode + subdivisionIso to the address row
  • Rendered EOI Address line format: street, city, REGION, postal, COUNTRY-ISO (e.g. 123 Sesame Street, Staten Island, NY, 10306, US) — shortest comprehensive form to fit the AcroForm box
  • Nationality removed from the required preview rows + Section 2 helper copy; the resident's country code on the Address line carries that meaning now

Interest Overview teaser:

  • "Latest note" no longer renders the raw authorId UUID — getInterestById now LEFT JOINs userProfiles so the teaser shows the author's display name (falls back to "Unknown" if the user row is missing)

Documenso integration polish (later in this session):

  • CRM-as-source-of-truth signer wiringbuildDocumensoPayload now resolves developer + approver name/email per port via: linked CRM user (documenso_<role>_user_iduserProfiles.displayName
    • user.email) → free-text override (documenso_<role>_email/name) → legacy eoi_signers JSON blob → empty (template wins). Replaces the hardcoded "David Mizrahi" / "Abbie May" placeholders.
  • EOI title format<full name>-EOI-NDA-<berthRange> (e.g. Matt Ciaccio-EOI-NDA-A2). Tested for single + multi-berth + no-berth.
  • v2 title PATCH after template/use — fixed broken endpoint shape: was PATCH /api/v2/envelope/{id} with {title}; correct is POST /api/v2/envelope/update with {envelopeId, data: {title}}. Restricted to DRAFT envelopes which is what we always have post-create.
  • document_signers rows inserted on generate — was missing; the EOI tab's "Signing progress" panel showed "No signers loaded" forever because the webhook handler only updates existing rows. Now the Documenso recipients array from /template/use is persisted at create time.
  • Interest milestone stampingeoiDocStatus='sent' + dateEoiSent flip immediately on generate so the Overview tab transitions out of the "Generate EOI" prompt without waiting for the next refresh.
  • Cache invalidation on generate — the EOI dialog's onSuccess now invalidates interests/{id}, interests/{id}/eoi-context, interests/{id}/timeline, and the documents predicate so every surface that shows the new EOI state updates without a manual refresh.
  • Dimension unit toggle in EOI drawer — ft/m chip in the Optional section header; defaults to the rep's original entry unit (yacht.lengthUnit). Drives the preview-row display + the Length/Width/Draft formValues sent to Documenso / filled into the in-app PDF.
  • EOI Address line formatstreet, city, REGION, postal, COUNTRY-ISO (e.g. 123 Sesame Street, Staten Island, NY, 10306, US). Inline fix-it form accepts all 5 fields. Nationality requirement gone — the resident's country code on the Address line carries that meaning now.
  • Soft-delete documentsdeleteDocument now sets status='deleted' + voids the upstream envelope (best-effort) + inserts a documentEvents row + audit-logs the action. No more hard delete that destroyed event history.
  • Signing-progress card redesign — vertical card list (no connector line), per-status visual treatments (pending/opened/signed/declined), initials-aware avatar with status icon overlay, state-aware action button ("Send invitation" before invitedAt, "Send reminder" after), precise timestamps in tooltips, and a Sequential / Concurrent signing-order badge in the EOI card header pulled from the synced template meta with fallback to per-port setting.
  • Phone-number formatting in interest Overview Contact section — uses libphonenumber-js formatInternational() so raw E.164 (+33633219796) renders as +33 6 33 21 97 96.
  • Interest detail header — added "Client page" button next to the Email/Call/WhatsApp quick actions.
  • Qualification "dimensions" criterion copy — "We know the vessel's length, width, and draft" → "Vessel dimensions OR desired berth dimensions are recorded (length, width, draft)" to reflect the auto-satisfy rule. Migration 0067 applied to dev DB.

9.Y Notifications: auto-mark-as-read on dropdown view

  • Where: NotificationItem / notifications dropdown components in src/components/notifications/.
  • Current: Notifications stay unread until the user clicks each one or hits "Mark all read".
  • Desired: Auto-mark each notification that was actually rendered in the dropdown as read. Two flavours to pick from:
    • (a) On display — fire markRead(id) as each item renders. Pro: matches Slack/Linear pattern. Con: unread bubble drops to 0 the moment the dropdown opens, even before the user has time to scan.
    • (b) On dropdown close — batch-mark every currently-rendered ID when the user closes the dropdown. Pro: bubble + bold styling stay while the dropdown is open so the user can re-find unread items.
  • Question for Matt: Pick (a) or (b)? The skipped (un-rendered) notifications stay unread either way.
  • Effort: Small. New POST /api/v1/notifications/mark-read-bulk endpoint (or per-id PATCH × N), useEffect in NotificationItem (option a) or onOpenChange handler on the dropdown (option b).

9.X Same-stage move emits a confusing notification

  • Where: changeInterestStage in src/lib/services/interests.service.ts (notification block at ~line 944) and any other call site that emits interest_stage_changed (advanceStageIfBehind, webhook auto-stamps, Documenso completion handlers).
  • Symptom: Notification panel shows e.g. "Marco Bianchi moved to EOI / Stage changed from EOI to EOI" — same-stage transition surfaces in the inbox even though changeInterestStage returns STAGE_NOOP for the matching pipeline-stage case.
  • Suspect: A different code path (likely advanceStageIfBehind or one of the Documenso-webhook auto-advance helpers) emits a stage-changed notification without checking oldStage !== newStage. Or STAGE_LABELS has two raw stage codes that map to the same display label so the notification reads "EOI → EOI" when the real codes were different.
  • Fix: Find the emitting site, add an oldStage === newStage early-return before createNotification. Audit the same guard on every other interest_stage_changed emitter. Same-stage events stay invisible to reps (they're already filtered out of the audit log via STAGE_NOOP).

4.7 Per-EOI reminder controls (with per-signer fine-tune)

  • Where: EOI card footer in interest-eoi-tab.tsx ("Reminders are rate-limited (max once per 7 days per signer)" line) + the per-signer rows in signing-progress.tsx.
  • Current: Single global per-port reminder_default_days setting + doc-level remindersDisabled + reminderCadenceOverride columns (the schema already has them — see documents.ts lines 102-103). No UI surfaces them.
  • Desired (tiered controls):
    • EOI-level: inline toggle to disable reminders entirely for this EOI + an inline "Remind every X days" picker that overrides the port default just for this document. Lives in the EOI card footer.
    • Per-signer fine-tune: each signer card on the signing-progress list gets its own "Remind this signer every X days" override picker AND a per-signer toggle. Reason from Matt: the developer is known to miss emails — they need a tighter cadence than the rest. Persists on a new JSONB column reminder_overrides on document_signers (or documents.reminder_overrides_by_signer_id keyed by signer id — either works; signer-level is more discoverable in the UI but locks the schema).
  • Resolution chain at reminder-send time (existing sendReminderIfAllowed worker): per-signer override → per-document override → per-port default → null (no auto-reminders).
  • Surface the active value on each card: small italic line under the signer row like "Auto-reminds every 3 days · last reminded 2 days ago" so the rep knows what's happening without digging.
  • Effort: Medium (~1-2h). Backend: new JSONB column + resolver branch in the reminder worker. Frontend: inline pickers on EOI card footer + per-signer cards. The schema columns remindersDisabled + reminderCadenceOverride are already there for the document level — the per-signer dimension is the only new thing.

4.8 Cancel EOI — signature-aware modal + honest "void" copy

  • Where: src/components/interests/interest-eoi-tab.tsx (the Cancel EOI button at the bottom of the EOI card) and src/lib/services/documents.service.ts's cancelDocument (already calls documensoVoid correctly — no backend change needed).
  • Why this matters: today the button reads just "Cancel EOI" and pops a single-line confirm. Two real problems:
    1. Documenso doesn't hard-delete on void. DELETE /api/v2/envelope/{id} (and the v1 DELETE) is a void, not an erase — Documenso retains the envelope under a "Voided" status for legal audit-trail reasons (industry-standard across DocuSign, HelloSign, Documenso). Reps see the cancelled doc still listed in Documenso and assume the void didn't fire. It did — it just didn't disappear.
    2. No protection against accidentally voiding partially-signed docs. If 1 of 3 signers already signed, the current single-line confirm doesn't surface that — clicking Cancel discards the captured signature with no warning.
  • What to ship:
    • Signature-aware confirmation modal that scales with state:
      • 0 signed → quick confirm. Copy: "Cancel this EOI? The envelope will be voided on Documenso and signers won't be able to access it." One click.
      • 1+ signed but not all → fuller warning modal listing {name} signed {humanRelative(signedAt)} per already-signed row. Body copy: "If you continue, those collected signatures will be discarded and the envelope voided. This is non-recoverable. Continue?"
      • All signed → block the cancel entirely (the deal is past the decision point). Offer an "Archive on our side" path instead that hides the doc from the active EOI list but leaves the Documenso envelope untouched as the legal record.
    • Button copy: "Cancel & void on Documenso" (replaces just "Cancel EOI") so the rep knows what happens upstream.
    • Helper tooltip / footer line explaining void ≠ delete and that legal traceability is the reason Documenso keeps the audit row.
    • After successful void, hide cancelled docs from the primary EOI tab list. Surface them under a "Cancelled" filter / pill so the rep can browse history without "ghost" clutter.
  • Backend: No change. cancelDocument already calls documensoVoid(documensoId) which DELETEs against Documenso v1 or v2 depending on the per-port apiVersion. Idempotent on 404.
  • Effort: Small (~45 min). New modal component, the signature-count branch logic, copy changes, filter UI on the EOI tab.

9.Y2 Dev-mode EMAIL_REDIRECT_TO badge across all surfaces

  • The quirk: env.EMAIL_REDIRECT_TO is a dev/staging guardrail that silently rewrites every outbound email recipient to a single address (matt@letsbe.solutions in current setup) so test EOIs / portal invites / completion emails can't accidentally land in a real client inbox. Hard-blocked in production (env.ts refuses to boot when both NODE_ENV=production and EMAIL_REDIRECT_TO are set). When the var is on, the originals are baked into the recipient name field as (was: <original-email>) so they're recoverable for audit.
  • Why the user is asking: today the redirect is invisible in the UI — reps see the SigningProgress card showing matt@letsbe.solutions for every recipient and think the per-role admin settings are broken. They aren't — the settings ARE saved correctly, the redirect just rewrites everything on the wire.
  • What to ship:
    • Add a small DEV REDIRECT → <address> pill in the global header / status bar when the var is set. Single source-of-truth for "I am in dev-redirect mode" so reps don't have to guess.
    • Surface a per-row "DEV REDIRECT" badge on every UI that shows a recipient email/name pair that the redirect modifies. Tooltip body: "This email would normally go to {original}. The dev-only EMAIL_REDIRECT_TO env var rewrote it to {redirectTo} so no real recipient is contacted. Unset the var to send for real."
  • Surfaces that need the badge (enumerate so the implementer doesn't miss any):
    1. SigningProgress card on the EOI / Reservation / Contract tabs — each signer row. The Option A treatment (show parsed (was: original-email) under the redirected email) ships now as part of this initiative's first slice; the global pill + other surfaces follow.
    2. Document detail page recipient list.
    3. "Send invitation" / "Send reminder" confirmation toasts.
    4. EOI generate drawer — preview Section 2 client email row.
    5. Contact log → email-out action surfaces (when we add direct email actions; some are placeholders today).
    6. Supplemental info request modal — the "request email will be sent to: client@email" preview line.
    7. Portal-activation send-out (scripts/dev-trigger-portal-invite.ts surfaces UI confirmation; need badge on the admin "resend activation" button).
    8. Password-reset / set-password confirmation modals.
    9. Signed-PDF completion email composer dialog ("Email signed PDF to all signatories").
    10. Outbound webhook dispatch UI in admin (webhooks are skipped when EMAIL_REDIRECT_TO is set, per workers/webhooks.ts line 89-104).
  • Plumbing: Add a tiny endpoint GET /api/v1/system/email-redirect-state returning {active: boolean, redirectTo: string | null}. A React context provider (<EmailRedirectProvider>) hydrates once at app boot and exposes it to a <DevRedirectBadge ifEmail={...}/> and <DevRedirectGlobalPill/> component. Each surface drops the component in next to the relevant recipient.
  • Audit-pair extraction helper. A shared parseRedirectedRecipient(name): { displayName, originalEmail | null } so every surface renders the (was: ...) consistently rather than doing ad-hoc regex matching like the cleanSignerName I just shipped.
  • Effort: Medium-high — backend endpoint + 2 React components + ~10 surface drop-ins. Touchpoint enumeration above means it's parallelisable across a few PRs.
  • Defers Option B: not pursued. The redirect is correct behavior for dev; the goal is making it visible, not hiding it.

9.Z BIG: in-app PDF field editor + template builder

  • Idea (Matt): Build a browser-side editor inside our admin that lets reps upload a PDF, place fillable fields on it (text / checkbox / date / dropdown / signature), connect each field to a CRM data token from VALID_MERGE_TOKENS (src/lib/templates/merge-fields.ts), and either use the result for in-app document generation OR push it to Documenso as a template via the API.
  • Why it's worth doing:
    • Centralizes template management in CRM (no Documenso UI round-trip just to fix a typo / move a field).
    • Unlocks custom one-off documents (port-specific addenda, info requests, reservation variants) that currently can't be filled by either pathway because there's no template editor.
    • Same field-to-token mapping logic shared between in-app fill and the Documenso push path.
  • Tech is all here — pdfme (already in stack) covers ~80%:
    • pdf-lib (already in stack) handles read/write of AcroForm fields + positioning + types — used for the AcroForm-import bridge below.
    • pdf.js renders PDF pages to a browser canvas at known DPI.
    • pdfme (already in stack) gives us almost the whole editor for free:
      • Designer class (@pdfme/ui) — drag-and-drop browser editor that mounts into a DOM element, takes any PDF as basePdf, lets users place + resize + rename + delete field schemas live. Built-in Ctrl+S save hook + change listeners + page navigation.
      • Built-in field types — text, image, signature, checkbox, radioGroup, qrcode, barcodes — each with position + size + per-type props (font, alignment, options for radio/dropdown).
      • Form class — preview-with-sample-data is exactly what this does out of the box (covers Matt's "placeholder per field" + "preview with real record" requirements with no extra build).
      • generateForm() — emits a real interactive AcroForm PDF (text/checkbox/radioGroup). End users can fill in Acrobat or any PDF viewer. This is the "fillable custom document" path Matt asked about.
      • generate() — flattened output for the "render + email/sign" path. Same template, different output mode.
      • Plugin API for custom field types (Plugin<HighlightSchema> etc). We'd use this to add a "Documenso signing widget placeholder" field type for the Documenso push path. ~50 lines per custom type.
    • Merge-token catalog already exists at src/lib/templates/merge-fields.ts (VALID_MERGE_TOKENS).
  • What pdfme does NOT cover — what we'd actually build:
    • AcroForm → pdfme schema import bridge (the one piece of net-new code). pdfme uses its own schema format, not AcroForm. To edit a PDF that was made in Acrobat, we use pdf-lib's form.getFields() to read every existing field's name + type + widget rect, generate matching pdfme schemas, strip the AcroForm from the basePdf (so pdfme owns placements). ~50100 lines.
    • CRM merge-token mapping UI. Repurpose pdfme schema's name field as the merge token, OR add a sidecar map keyed by schema id. Add a token-picker dropdown to each schema's pdfme edit panel. ~20 lines.
    • Documenso template push. POST /api/v2/template/create with multipart {PDF, recipient slots, field placements as %-page coords}. %-coords are computable from pdfme schema positions.
  • Phased plan:
    • Phase 1 — MVP (12 weeks focused). Text + checkbox AcroForm fields, in-app fill at generate-time only. New document_templates row variant with a field_map JSONB ({fieldName: tokenOrSlot}). Save path rewrites PDF via form.createTextField/Checkbox/Dropdown + addToPage. Generation fills AcroForm by name → resolved token value.
    • Phase 2 — Documenso template push (+23 weeks). POST /api/v2/template/create with multipart {PDF, recipient slots, field placements as %-page coords}. Editor grows recipient-slot config + signing-widget overlay placement (Documenso treats sig fields as overlays, not AcroForm — store separately). Returned templateId + recipient IDs persisted on our template row so the existing documenso-template pathway can use it.
    • Phase 3 — Polish (+12 weeks). All field types incl. radio groups, conditional fields, validation rules, drag-snap-to-grid, page-aware preview, dim mode.
  • Risk areas to flag upfront:
    • PDF coordinate gotchas (pdf-lib origin bottom-left vs canvas top-left; rotated pages; flate-compressed pages need round-trip).
    • AcroForm subtleties beyond text — radio groups + embedded JS.
    • Documenso's %-coord placement is finicky; needs a preview iteration loop or "looks right in browser" doesn't = "right when Documenso renders".
  • Two additional requirements from Matt:
    • Placeholder / preview text per field. Each field config gets a "Preview value" input alongside its name + token mapping. Live preview re-renders the current page with placeholders filled (overlay on the pdf.js canvas for live-typing, or pdf-lib fill + re-render). Ship a "Preview with real record" mode too — pick an existing client/interest/etc and render with that record's actual values, so long French street names + non-Latin scripts + joint- signer names actually surface their overflow cases. This is the real-world bench for what would otherwise need a full EOI send to catch.
    • Edit existing Acrobat-authored fields. On upload, read every AcroForm field via form.getFields() and render them as already- placed boxes on the canvas. Select / rename / resize / reposition / delete each. Quirk: pdf-lib doesn't expose setPosition directly on a widget — workaround is read-properties → delete → recreate at the new coords. Plain text/checkbox/dropdown/radio fields are fully preserved; calculated fields / embedded JavaScript will not survive a save (pdf-lib doesn't round-trip JS — flag this loudly in the UI if the loaded PDF contains scripted fields). Acrobat's "appearance streams" need regeneration via form.updateFieldAppearances() on save.
    • This explicitly covers the recurring "open my Acrobat-made EOI source PDF, move the Email field down 6px, save it back" workflow that currently requires bouncing to Acrobat for every pixel-level template adjustment.
  • Decision needed from Matt: Ship Phase 1 alone first (biggest win, shortest path, in-app custom docs immediately) or wait until we can scope Phases 1+2 together?

9.V Audit log — expandable rows with full detail

  • Where: /admin/audit page; audit-log-list.tsx table + the already-built AuditLogCard component.
  • Symptom: Rows show a placeholder summary (entity, action chip, changed-fields list, actor name + email, short IP) but can't be clicked to expand. The actual oldValue / newValue / metadata / full userAgent are stored in DB and even typed on AuditLogCard — they're just not surfaced on desktop.
  • What to ship:
    • Make each row click-to-expand OR open a side Sheet (right slide-in). Side Sheet is cleaner because oldValue / newValue can be deeply nested JSON that doesn't render well inline. Less reflow on a long-list page.
    • Detail panel content (everything the audit table currently hides):
      • Full timestamp with ms precision + relative ("about 1 hour ago")
      • Action + entity + entityId (clickable deep-link to the entity page where possible)
      • Actor block: display name, email, userId, port name + slug
      • Network: full IP, full userAgent string, request-id if present, port context, port + global flags
      • Side-by-side oldValue / newValue diff — JSON pretty-printed with the changed keys highlighted (mirror the git-diff look: removed red, added green). Falls back to plain pretty-print for view / delete events where one side is null.
      • Metadata block — pretty-printed JSON
      • Related events: any audit-log siblings emitted within the same request-id, listed compactly so a forensic trace of a multi-step action is one click away.
    • "Copy as JSON" button on the detail panel for forensic exports.
  • Effort: Medium (~2h). Wire cardRender style detail to a Sheet on click + a JSON diff visualiser. The existing AuditLogCard already has most of the layout — promote it into the Sheet body.

9.W Admin email-routing — Sales send-from card bugs

  • Where: src/components/admin/sales-email-config-card.tsx + src/components/admin/email-routing-card.tsx on the /admin/email-routing page.
  • Bugs (compound):
    1. Sales card shows noreply creds as "already saved." The screenshot field values (mail.letsbe.solutions, noreply@letsbe.solutions, dotted password) come from smtp_*_override (the noreply/transactional keys). The actual sales_smtp_* keys are empty in the DB. The form should render empty placeholders when nothing is saved for sales — OR explicitly show a "Inherits from noreply above" badge with a "Configure separately" CTA. Today reps think they've configured sales when they haven't.
    2. No independent Save button. The Sales card piggy-backs on the overall page save. It's a separate account with separate creds — should have its own Save button keyed to the sales_smtp_* subset of settings, mirroring the noreply card pattern.
    3. Description is ambiguous. Current copy: "SMTP credentials for human-touch outbound (brochures + per-berth PDFs)". Doesn't say this is a separate account from the noreply one. Update to: "Optional dedicated SMTP for sales-team-initiated emails (brochures, per-berth PDFs, signed-doc completions). Distinct from the noreply transactional account configured above. When unset, all outbound falls back to the noreply account."
    4. No Test connection button on either Sales or Noreply cards. Add Test connection that opens an SMTP connection to the configured host:port with the saved password, ATTLS-upgrades when SSL is off, AUTH-LOGINs as the configured user, and reports success / specific error. Existing documenso/test and imap-probe patterns are a good template.
    5. Bottom-of-page "automated email sending addresses" list lies. It shows sales@… even when sales SMTP is empty (so those flows actually fall through to noreply). The list must read the resolved-effective-address for each flow, not the configured one, and tag each entry "via sales" or "via noreply (sales not configured)".
  • Plumbing already in place:
    • sales-email-config.service.ts SETTING_KEYS has the per-key constants: sales_smtp_host, sales_smtp_port, sales_smtp_secure, sales_smtp_user, sales_smtp_pass_encrypted.
    • email-routing.ts already routes per-flow (brochure / berth-PDF / signed-doc completion → sales; activation / portal / digest → noreply). The fallback when sales is empty needs to be surface-honest, not silent.
  • Effort: Medium (~2-3h). Form split + new mutation per card + test-connection endpoints (×2) + bottom-list resolver tweak.

11 · URGENT bugs surfaced 2026-05-15 end-of-session

11.1 Webhook can't resolve v2 envelope (numeric vs envelope_xxx ID mismatch)

  • Where: resolveWebhookDocument in src/lib/services/documents.service.ts.
  • Symptom: Documenso fires webhook after signer signs, our endpoint returns 200, but logs "Document not found for webhook (port-scoped)" and never updates document_signers.status or interest.eoiDocStatus. UI stays "Awaiting signatures" forever even after all signers have completed in Documenso. Matt's session: signed in Documenso → CRM shows 0/3 signed indefinitely.
  • Root cause: Documenso v2 webhook payload's payload.id is the internal NUMERIC pk (e.g. 19). We store documents.documenso_id as the ENVELOPE_XXX string identifier (after the normalizer fix that made title-update + distribute work). The two never match. Documenso webhook payloads do NOT carry the envelope_xxx string identifier at all — per their docs.
  • Fix options (Option A recommended):
    • A. Add documents.documenso_numeric_id column. Capture Documenso's numeric id from /template/use response at create time (it's there alongside envelopeId). Webhook resolver tries documenso_id OR documenso_numeric_id. Backfill script for existing rows iterates GET /envelope to map.
    • B. Resolve by externalId. Documenso webhook payload includes externalId (we set loi-<interestId>). Add documents column for externalId, resolve by it as fallback. Doesn't help non-EOI doc types that don't set externalId.
    • C. Synchronous API translation. Webhook receiver sees numeric ID → calls Documenso GET /envelope/{numericId} → finds the envelope_xxx → resolves our doc. Adds API round-trip per webhook.
  • Effort: Small-medium (~1-2h). Migration + capture-at-create + resolver chain.
  • Until shipped: every signed / viewed / rejected event is silently dropped on the floor.

11.2 Sequential signing not actually enforced — envelope is PARALLEL

  • Where: v2 branch of documensoGenerateFromTemplate in src/lib/services/documenso-client.ts.
  • Symptom: Matt signed as signer #2 (Developer) before signer #1 (Client) on an EOI marked "SEQUENTIAL" in the CRM UI. Documenso accepted both signatures.
  • Root cause: /template/use doesn't accept a meta field at all — our payload's meta.signingOrder: 'SEQUENTIAL' is silently dropped. The envelope inherits the TEMPLATE's stored signingOrder, which defaults to PARALLEL on v2 templates unless explicitly set via the template editor. Our follow-up /envelope/update call sets the title but NOT the signingOrder. So envelope ships PARALLEL and any signer can sign at any time. The local "Sequential" badge on the EOI card reads our per-port setting — not the envelope's actual state.
  • Fix: In the v2 generate path, after the title /envelope/update, send a second /envelope/update with meta: { signingOrder: <resolved> }. Verify it stuck by re-reading the envelope's documentMeta.signingOrder. Pair with the existing signing-order display fix (read authoritatively from the sync report / envelope meta, not the local setting).
  • Effort: Small (~30 min). One additional update call + verify step.

11.3 Automated emails — full refactor (luxury-port tone + old-system copy + per-port branding)

  • Where: every file under src/lib/email/templates/.
  • What Matt called out:
    • Tone: current copy reads unprofessional for a luxury port. Refactor every template to match the tone/voice of the old CRM repo's email templates (locate in: /Users/matt/Repos/Port Nimara or related — needs locating).
    • Subject format: "{first_name}, your EOI for {portName} is ready to be signed". Currently a flat title-cased "Expression of Interest ready to sign — Port Amador".
    • Per-signer copy: confirm with Matt whether each role gets unique body copy or a single template that branches by signerRole. If unique → load the old system's per-role copy.
    • Signature attribution: today reads "Thank you, Developer, Port Amador" — the literal placeholder name. Should pull the actual sender's display name + port name (e.g. "Sales Team, Port Amador" or the linked-CRM-user's display name from documenso_developer_user_id).
    • Per-port branding (logo, colors, background image): registry has branding_logo_url, branding_primary_color, branding_app_name, branding_email_header_html, branding_email_footer_html. Verify those flow into the email templates' header — Matt saw Port Nimara branding on a Port Amador EOI which suggests the per-port branding chain isn't being honoured at render time. (User retracted the wrong-port claim — was confused — but the branding question is still valid: confirm branding_logo_url per-port resolves correctly.)
    • Greeting cleanup: today renders Dear Matt Ciaccio (was: matt@letsbe.solutions), because the redirect-helper bakes the original email into the name. We stripped this at the signer insert step today — confirm the email greeting reads cleanly after the latest fix.
    • "Signing happens directly inside our website — your data isn't sent to a third-party signing service": misleading if the marketing-site embed isn't set up + the link goes straight to Documenso. Copy should branch on whether the embedded_signing_host is configured + verified (pairs with §4.9a).
  • Effort: Large (~1 day). Audit every template, replace tone, rewire branding resolution if broken, refactor the per-role branching logic, verify subject format + signature attribution pull from the right sources.

10 · Known broken (pre-existing, not from current work)

10.1 Documenso webhook integration tests

  • Where: tests/integration/documenso-webhook-route.test.ts (4 of 5 tests).
  • Symptom: "Document not found for webhook (port-scoped)" — the secret matches but to the wrong port, so the port-scoped document lookup misses.
  • Root cause: The env-to-admin migration earlier in the session made DOCUMENSO_WEBHOOK_SECRET optional in env.ts. The test now sends env.DOCUMENSO_WEBHOOK_SECRET ?? ''. The receiver's listDocumensoWebhookSecrets() prefers per-port DB rows over the env fallback — and a stale port-scoped row in the test DB matches the same shared your-webhook-secret-min-16-chars first, capturing the request for the wrong portId.
  • Fix: Have each test seed the secret on its makePort() port (insert into system_settings with key documenso_webhook_secret, value encrypted, portId = the freshly created port). Then the test-created port wins the secret match.
  • Effort: ~10 min.

How to use this doc

  1. Pick an area (top-of-file index) and work through items in order.
  2. Most items reference a specific file or service — start there.
  3. The "Effort" tag is rough: Small = ~30 min, Medium = ~1-2h, Medium-high = ~half-day.
  4. When you finish an item, move it to a "Done" section at the bottom (or open a PR that references the heading).
  5. Items 4.1, 3.1, and 1.1-1.2 are the most user-visible if you want to pick a high-impact first slice.

For Matt: the "Decision needed" line in 3.2 (reminders) and the "save for end" note in §8 (supplemental form) need a call before they can be picked up.


After this doc — older audit follow-ups still live elsewhere

This file is the current testing-cycle backlog (everything found during the Documenso v2 buildout + manual click-through 2026-05-15 onward). Once we work through this list, do NOT close out the audit process — earlier passes have their own punch-lists that still need to be tackled:

  • docs/AUDIT-FOLLOWUPS.md — the rolling deferred-item index from every audit so far. Single source of truth across cycles.
  • docs/audit-comprehensive-2026-05-05.md + the 2026-05-06 frontend audit + docs/AUDIT-CATALOG.md (320+ check catalog) — pre-Documenso findings, many of which haven't been touched yet.
  • docs/audit-final-deferred.md — items explicitly punted with rationale; revisit each entry's "should we?" verdict now that surrounding code has moved.
  • docs/audit-2026-05-15.md + docs/AUDIT-FINDINGS-2026-05-15.md — the comprehensive Playwright sweep findings that were partially fixed (A1/A2/A4/A6/A8/A9/A16/A17/A19/A20) but had a long tail still open before the Documenso work began.

When this doc is done: re-open those audit punch-lists, dedupe anything we accidentally already fixed during Documenso work, and start the next ship cycle from there. Don't lose work between cycles.