# 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 check** — `getDocumentsForInterest` /
     `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.ts` →
  `documensoGenerateFromTemplate` 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
     `qualified` → `eoi`. 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 repo** — `wrapBrandedSigningUrl` 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.

### 4.9 Marketing-site embedded signing link

- **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 · Global search

### 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 fragments** — `clients.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 wiring** — `buildDocumensoPayload` now
  resolves developer + approver name/email per port via:
  linked CRM user (`documenso_<role>_user_id` → `userProfiles.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 stamping** — `eoiDocStatus='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 format** — `street, 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 documents** — `deleteDocument` 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). ~50–100 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 (1–2 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 (+2–3 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 (+1–2 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.
