1623 lines
88 KiB
Markdown
1623 lines
88 KiB
Markdown
|
|
# 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.
|