feat(post-audit): batch A+B quick-wins + audit-side residuals
Bundles the user-prioritised follow-ups from the post-audit punch-list.
Batch A — pipeline + EOI safety:
- §1.1 timeline buildAuditDescription renders diff fields ("leadCategory → hot_lead").
- §4.13 EOI rejection cascade: notification to assigned rep + audit row + rose banner.
- §4.10b finish doc-detail: SigningProgress reuse, linked-entity names (server-resolved),
per-event icons + tooltips + show-more in activity panel.
- §7.2 stage guidance card replaces empty Payments slot pre-reservation.
- §4.15 deal-pulse trigger audit (docs/deal-pulse-trigger-audit.md).
Batch B — UX consistency + docs:
- §1.4 quick log-contact button on interest header.
- §2.1 contact-log compose: Dialog → Sheet.
- §7.1 docs/deal-pulse explainer page; /docs/ in PUBLIC_PATHS.
- DocumentStatus now includes 'rejected' + 'declined' across constants, labels, tone maps.
Audit-side residuals:
- M-NEW-1 /me/ports skips port-context requirement.
- M-AU03 audit log CSV export endpoint + UI button.
- M-IN03 dead receipt-scanner.ts deleted; live path already per-port.
- M-P01 pg_trgm GIN indexes (migration 0071).
- §10.1 webhook tests verified passing (was stale).
Deferred per user direction:
- §11.3 email copy refactor (needs old-CRM reference).
- M-EM03 IMAP bounce-to-interest linking.
Tests: 1374/1374. tsc + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
251
docs/POST-AUDIT-SPEC-2026-05-18.md
Normal file
251
docs/POST-AUDIT-SPEC-2026-05-18.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Post-Audit Implementation Spec — 2026-05-18
|
||||
|
||||
Captures the design decisions from the post-audit conversation so the
|
||||
implementation can start without re-litigating the trade-offs. Each
|
||||
section ends with an Effort estimate.
|
||||
|
||||
---
|
||||
|
||||
## 1. EOI document field overrides
|
||||
|
||||
### Goal
|
||||
|
||||
When generating an EOI, the rep should be able to override pre-filled
|
||||
field values (contact info, addresses, yacht details) while preserving
|
||||
the canonical record. Manual entries persist as tracked secondary
|
||||
values so future EOIs can pick them up from a dropdown.
|
||||
|
||||
### Design
|
||||
|
||||
**Client contact channels (email, phone):**
|
||||
|
||||
- The EOI form's email/phone fields render as a dropdown of every
|
||||
`client_contacts` row for the linked client, defaulting to the primary
|
||||
for each channel.
|
||||
- Rep types a brand-new value → on EOI save, a new `client_contacts`
|
||||
row is created with `is_primary=false`, `source='eoi-custom-input'`,
|
||||
`source_document_id=<doc-id>`. Labelled `[EOI]` on the client detail
|
||||
page contacts panel.
|
||||
- The current EOI uses the new value; future EOIs default to primary
|
||||
unless the rep explicitly picks the new row from the dropdown.
|
||||
- A "Set as default for future documents" toggle on the EOI form
|
||||
promotes the new value to `is_primary=true` (demoting the prior
|
||||
primary).
|
||||
|
||||
**Client addresses:** Same pattern via `client_addresses` (which is
|
||||
already multi-value per CLAUDE.md).
|
||||
|
||||
**Yacht name + dimensions:** Yachts are single-valued; rep needs a
|
||||
different yacht → opens a "Create yacht" modal inline, fills in name +
|
||||
dims for the new yacht record, linked to the same client/interest, tagged
|
||||
`eoi-generated`. The EOI uses the new yacht. The original yacht is
|
||||
unchanged. (No yacht_aliases / yacht_dimension_overrides table.)
|
||||
|
||||
**Interest-specific fields (rare):** Same dropdown pattern via the
|
||||
existing fields on the interest record. Custom entries promote-or-stay
|
||||
following the toggle.
|
||||
|
||||
**Audit trail:** Every override action (create-non-primary, promote-to-
|
||||
primary, create-yacht-from-eoi) emits an audit_log row with action
|
||||
`eoi_field_override` and metadata identifying the source document.
|
||||
|
||||
**Per-document override (no record-side write):** Doc-level overrides
|
||||
remain available as a checkbox — when ticked, the value lives only on
|
||||
the doc and never touches client_contacts. Default is unchecked.
|
||||
|
||||
### Schema additions
|
||||
|
||||
- `client_contacts.source text` — extend the existing enum: `'manual'`,
|
||||
`'imported'`, `'eoi-custom-input'`.
|
||||
- `client_contacts.source_document_id text references documents(id)
|
||||
on delete set null` — surfaces the originating EOI.
|
||||
- `client_addresses.source` + `source_document_id` (mirror).
|
||||
- `yachts.source` + `source_document_id` (mirror; nullable so existing
|
||||
records aren't disturbed).
|
||||
- `audit_actions` enum gains `eoi_field_override` + `promote_to_primary`.
|
||||
|
||||
### UI
|
||||
|
||||
- EOI Generate drawer: each editable field becomes either a `<Combobox>`
|
||||
(when multi-value) or `<Input>` + "Save as new …" hint (yacht).
|
||||
- Below each field: `[ ] Use only for this EOI` checkbox (default off)
|
||||
- `[ ] Set as default for future docs` checkbox (default off).
|
||||
- Client + Yacht detail panels: `[EOI]` badge on non-primary rows;
|
||||
"Set as primary" action on each.
|
||||
|
||||
### Effort
|
||||
|
||||
~1–1.5 weeks. Bundle the schema + EOI form + client/yacht detail UI
|
||||
into one PR (user picked "All at once").
|
||||
|
||||
### Open implementation questions
|
||||
|
||||
- The yacht-creation inline modal needs the existing YachtForm wired in;
|
||||
on save it tags the new yacht with the eoi-generated marker. Tag the
|
||||
yacht via `tags`? Or a dedicated `source` column? Recommend column
|
||||
for queryability.
|
||||
- Should `[EOI]` badges fade out after a TTL or stay forever? Recommend
|
||||
forever — the rep deliberately chose this label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Reminders
|
||||
|
||||
### Goal
|
||||
|
||||
Reps can: per-interest follow-up cadence with note + time, standalone
|
||||
tasks (no entity), assignable-to-another-rep tasks. The existing rich
|
||||
`reminders` table holds the canonical data; the per-interest cadence
|
||||
on the `interests` row stays for backward compat as a quick-tick.
|
||||
|
||||
### Design
|
||||
|
||||
**Per-interest cadence (kept):**
|
||||
|
||||
- `interests.reminderEnabled` + `interests.reminderDays` retained.
|
||||
- New: `interests.reminderNote text NULL` — surfaced in the
|
||||
notification body + the inbox row.
|
||||
- The cadence fires a row into `reminders` on each tick (with
|
||||
`interest_id` set) instead of the current ad-hoc notification flow,
|
||||
unifying the inbox.
|
||||
|
||||
**Standalone tasks (new):**
|
||||
|
||||
- Rich `reminders` table already has every column we need (title, note,
|
||||
priority, due_at, assigned_to, snoozed_until, google_calendar_event_id).
|
||||
- Two UI surfaces (both submit to the same dialog component):
|
||||
- RemindersInbox top-right `[+ New task]` button.
|
||||
- Per-entity detail page (interest, client, berth, yacht): `[+ Task]`
|
||||
button inside the existing Reminders section. Linked-entity field
|
||||
pre-filled and locked.
|
||||
- The dialog: Title (required), Note (optional), Due date+time,
|
||||
Priority, Assign to (default = current rep), Linked entity
|
||||
(optional dropdown for inbox surface; locked for per-entity).
|
||||
|
||||
**Time-of-day:**
|
||||
|
||||
- New user-settings field: `digest_time_of_day time, default '09:00'`.
|
||||
Stored in user_profiles.
|
||||
- Per-reminder override: each reminder's `due_at` carries the exact
|
||||
firing moment (existing column). The dialog defaults the time picker
|
||||
to the user's `digest_time_of_day` but lets them override per row.
|
||||
- Worker scheduler: a 15-min cron tick scans `reminders` for rows whose
|
||||
`due_at <= now() AND fired_at IS NULL`, fires the notification, sets
|
||||
`fired_at`.
|
||||
|
||||
**Assignment:**
|
||||
|
||||
- `reminders.assigned_to` (existing). Dialog has an "Assign to" picker
|
||||
(port users via /api/v1/admin/users/picker), defaults to current user.
|
||||
- Inbox shows the assignee chip when not me; filter `[Mine | All my port]`.
|
||||
|
||||
### Schema additions
|
||||
|
||||
- `interests.reminder_note text NULL`
|
||||
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`
|
||||
- `reminders.fired_at timestamptz NULL` (new — drives the worker idempotency)
|
||||
- No new tables. The existing `reminders` table covers standalone tasks.
|
||||
|
||||
### UI
|
||||
|
||||
- `<CreateReminderDialog>` component (shared).
|
||||
- RemindersInbox: `[+ New task]` button → dialog (linked entity blank).
|
||||
- Interest / client / berth / yacht detail pages: existing Reminders
|
||||
section gains `[+ Task]` button → dialog (linked entity pre-filled,
|
||||
field disabled).
|
||||
- Settings page: time picker for "default reminder time" → writes
|
||||
`user_profiles.digest_time_of_day`.
|
||||
|
||||
### Effort
|
||||
|
||||
~3–4 days. Schema migration + dialog component + 4 entity-page wires
|
||||
|
||||
- worker scheduler refactor + inbox filter.
|
||||
|
||||
---
|
||||
|
||||
## 3. Supplemental info form — per-port setting
|
||||
|
||||
### Goal
|
||||
|
||||
The "Send supplemental info form" link in the auto-email should resolve
|
||||
to the marketing site when configured; fall back to a CRM-hosted route
|
||||
otherwise. Confirmed: per-port setting.
|
||||
|
||||
### Design
|
||||
|
||||
- New system_settings key: `supplemental_form_url` (per-port, optional,
|
||||
text). Defaults to NULL.
|
||||
- Link generator in the email service:
|
||||
```ts
|
||||
const url = cfg.supplementalFormUrl
|
||||
? `${cfg.supplementalFormUrl}?token=${raw}`
|
||||
: `${env.APP_URL}/supplemental/${raw}`;
|
||||
```
|
||||
- Existing `/supplemental/[token]` CRM route stays as the fallback. Add
|
||||
a "Loading…" skeleton + dual-mode copy ("If you don't see your
|
||||
details, contact your rep").
|
||||
- Admin UI: add the field to `/admin/email/page.tsx` (or a new
|
||||
`/admin/supplemental/page.tsx`) — single text input with the help
|
||||
hint "Leave blank to use the built-in CRM page."
|
||||
|
||||
### Effort
|
||||
|
||||
~2 hours (single setting + 1 admin field + link resolver).
|
||||
|
||||
---
|
||||
|
||||
## 4. Documenso phases 2 → 7 → 5 (you picked Phase 7 first)
|
||||
|
||||
### Phase 7 — Project Director RBAC (~1h)
|
||||
|
||||
- Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`
|
||||
pointing at the existing `developer_user_id` + `approver_user_id`
|
||||
settings.
|
||||
- Auto-fill name/email from the selected user (read via
|
||||
/api/v1/admin/users/picker).
|
||||
- Webhook handler in `src/app/api/webhooks/documenso/route.ts`: when an
|
||||
event arrives for the developer or approver, also fire an in-CRM
|
||||
`documenso:signed` notification routed to the linked user's CRM
|
||||
notifications inbox.
|
||||
|
||||
### Phase 2 — Webhook handler enhancement (~3–4h)
|
||||
|
||||
- Cascading "your turn" emails: when signer N completes, fire an
|
||||
invitation email to signer N+1 (sequential signing only).
|
||||
- On-completion PDF distribution: when status flips to COMPLETED,
|
||||
email the signed PDF to all `documents.completion_cc_emails`.
|
||||
- Token-based recipient matching: prefer `signing_token` over email
|
||||
for webhook → signer resolution (handles aliased emails).
|
||||
- Idempotency lock: replace the current body-hash dedup with a
|
||||
composite `(documensoDocumentId, recipientEmail, eventType)` unique
|
||||
constraint on documentEvents.
|
||||
- Schema is already in place from Phase 1 — this is pure handler logic.
|
||||
|
||||
### Phase 5 — Embedded signing URL verification (~1–2h)
|
||||
|
||||
- Confirm the marketing site's `/sign/<type>/<token>` page handles
|
||||
every signer-role × documentType combo.
|
||||
- Update `signerMessages` map in the signing-invitation email template
|
||||
to surface role-specific copy.
|
||||
- Apply nginx CORS block from the integration audit (constrain
|
||||
Documenso webhook origin).
|
||||
|
||||
### Effort total
|
||||
|
||||
~6–7h across the three phases. Phase 4 (field placement UI, 10–14h)
|
||||
stays deferred — covered separately by the PDF template editor work
|
||||
you picked Phases 1+2 for.
|
||||
|
||||
---
|
||||
|
||||
## What I'll build first
|
||||
|
||||
Per your sequencing:
|
||||
|
||||
1. Documenso Phase 7 (~1h) — unblock the linked-user signing UX.
|
||||
2. Supplemental form per-port setting (~2h) — small win.
|
||||
3. Documenso Phase 2 (~3–4h) — meaningful UX improvement.
|
||||
4. Documenso Phase 5 (~1–2h) — security + role copy.
|
||||
5. EOI field overrides + reminders (~1.5 weeks combined) — the big
|
||||
ones, picked up after the Documenso quick wins land.
|
||||
134
docs/deal-pulse-trigger-audit.md
Normal file
134
docs/deal-pulse-trigger-audit.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Deal Pulse & Pipeline Trigger Audit — 2026-05-18
|
||||
|
||||
Per MANUAL-TESTING-BACKLOG-2026-05-15 §4.15: map every place that
|
||||
moves an interest's pipeline stage OR contributes to the deal-pulse
|
||||
score, and call out the gaps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pipeline-stage auto-advance — call-site map
|
||||
|
||||
`advanceStageIfBehind(interestId, portId, target, meta, reason?)` is
|
||||
the canonical "advance if not already past target" helper. The
|
||||
`*Gated` variant honours the per-port `stage_advance_rules` setting
|
||||
(auto / suggest / off).
|
||||
|
||||
| Trigger | Caller | Target | File:line | Gated? |
|
||||
| ------------------------------------ | ----------------------------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------- |
|
||||
| EOI sent (manual rep generate) | `generateAndSign` | `eoi` | `documents.service.ts:843` | gated (eoi_sent) |
|
||||
| EOI signed (all parties via webhook) | `handleDocumentCompleted` | `reservation` | `documents.service.ts:1610` | gated (eoi_signed) |
|
||||
| Reservation signed | `handleDocumentCompleted` | `reservation` (no change, stage stays + status sub-flips) | `documents.service.ts:1640` | gated (reservation_signed) |
|
||||
| Deposit received in full | `recordPayment` | `deposit_paid` | `payments.service.ts:134` | gated (deposit_received) |
|
||||
| Sales contract signed | `handleDocumentCompleted` | `contract` | `documents.service.ts:1671` | gated (contract_signed) |
|
||||
| Deposit invoice paid (alt path) | `markInvoicePaid` | `deposit_paid` | `invoices.ts:684` | gated (deposit_received) |
|
||||
| Custom document upload | `confirmCustomDocumentUpload` | document-type-specific (eoi/reservation/contract) | `custom-document-upload.service.ts:354` | **NOT gated** (uses base helper) |
|
||||
| External-eoi mark-as-signed | inline in handler | `reservation` | `documents.service.ts:859` | **NOT gated** |
|
||||
| Externally-signed contract | inline in handler | `contract` | `documents.service.ts:971` | **NOT gated** |
|
||||
| Manual stage move | `changeInterestStage` | any (with override) | `interests.service.ts:840` | manual / not gated |
|
||||
|
||||
### Gaps flagged
|
||||
|
||||
- **External-signed paths bypass the per-port rules.** A port set to
|
||||
`suggest` for `eoi_signed` still gets an auto-advance when the rep
|
||||
marks the doc externally signed. Decision needed: should the rules
|
||||
table also gate the external-signed paths? Argument for yes: the
|
||||
rep's intent ("I just want to mark this signed") is the same as
|
||||
the webhook case. Argument for no: the rep is explicitly choosing
|
||||
to bypass the digital flow, so an auto-advance is what they expect.
|
||||
- **Custom document upload is not gated.** Same trade-off as above.
|
||||
- **No stage rollback on rejection.** When a signer declines an EOI
|
||||
(`handleDocumentRejected`), the doc flips to `rejected` but the
|
||||
interest stays at `eoi`. Confirm: this is correct — the deal
|
||||
isn't dead, the EOI is. Rep should regenerate. **Verdict: keep
|
||||
as-is.**
|
||||
- **No stage rollback on cancel.** When the rep cancels an in-flight
|
||||
EOI, the doc flips to `cancelled` and the interest stays at `eoi`.
|
||||
Decision needed: should the interest roll back to `qualified`
|
||||
when the only EOI is cancelled with no replacement?
|
||||
**Recommendation: NO** — keeps history honest; a cancel is the
|
||||
rep's deliberate signal that they're regenerating, not retreating.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deal-pulse signals — `computeDealHealth` map
|
||||
|
||||
Source: `src/lib/services/deal-health.ts`. Each `signals.push` site
|
||||
documented with its trigger condition + score delta:
|
||||
|
||||
| Signal | Delta | Condition | File:line |
|
||||
| ------------------- | -------------------- | --------------------------------------------------- | ------------------ |
|
||||
| `active_engagement` | +5 | Any contact-log entries in last 7 days | deal-health.ts:101 |
|
||||
| `contact_recent` | +20 | `dateLastContact <= 7 days` ago | deal-health.ts:115 |
|
||||
| `contact_warm` | +10 | `dateLastContact <= 14 days` (else of above) | deal-health.ts:122 |
|
||||
| `contact_stale` | -15 | `dateLastContact >= 30 days` | deal-health.ts:129 |
|
||||
| `stage_progress` | +10/+20/+30 (capped) | Per pipelineStage index | deal-health.ts:142 |
|
||||
| `stuck_top_funnel` | -10 | `firstDays >= 30` AND stage in {enquiry, qualified} | deal-health.ts:157 |
|
||||
| `eoi_awaiting` | -10 | `eoiSentDays >= 14` AND not signed | deal-health.ts:173 |
|
||||
| `deposit_pending` | -10 | reservation signed >= 21d AND no deposit | deal-health.ts:184 |
|
||||
| `contract_awaiting` | -10 | contract sent >= 14d AND not signed | deal-health.ts:200 |
|
||||
|
||||
### Positive signals that are MISSING (gaps)
|
||||
|
||||
- **EOI sent** — no `eoi_sent_recent` signal. Sending an EOI is the
|
||||
single biggest "this deal just got serious" moment but the score
|
||||
doesn't move when it happens. **Recommendation: +15 at < 7 days.**
|
||||
- **Deposit received** — same gap. A deposit landing should bump the
|
||||
score significantly. **Recommendation: +20, decays over 30 days.**
|
||||
- **Contract signed** — terminal positive event; should ladder the
|
||||
deal to its max. **Recommendation: +30 at < 14 days.**
|
||||
|
||||
### Negative signals that are MISSING (gaps)
|
||||
|
||||
- **Signer declined / EOI rejected** — when the §4.13 rejection path
|
||||
fires, the score should drop noticeably (the deal is suddenly at
|
||||
risk). **Recommendation: -25, decays over 14 days.**
|
||||
- **Interest archived-and-unarchived cycle** — zombie deals that
|
||||
bounce in and out should be flagged. Detect via the audit-log
|
||||
archive/restore pattern. **Recommendation: -10 if archived+restored
|
||||
within last 30 days.**
|
||||
- **Reservation cancelled** — similar to EOI rejected; signals the
|
||||
deal is at risk. **Recommendation: -20.**
|
||||
- **Berth status flipped to sold-to-other** — the deal's primary
|
||||
berth was sold to a different interest. **Recommendation: -30
|
||||
(catastrophic).**
|
||||
- **Signer engagement** — Documenso fires `RECIPIENT_VIEWED`
|
||||
webhooks (we store `openedAt`). A signer who opened but didn't
|
||||
sign in 7+ days = stalling. **Recommendation: -5 per stalling
|
||||
signer.**
|
||||
|
||||
### Cadence escalation (currently flat)
|
||||
|
||||
- `eoi_awaiting` and `contract_awaiting` both apply a flat -10 at
|
||||
the 14-day threshold. **Recommendation: ladder to -20 at 21d, -30
|
||||
at 30d** so prolonged stalling shows up more visibly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heat tooltip explainer copy
|
||||
|
||||
The DealPulseChip popover (`src/components/interests/deal-pulse-chip.tsx`)
|
||||
references signals by name. With the gaps above closed, the
|
||||
tooltip's enumerated list needs the new signals added so the in-app
|
||||
copy matches the computation.
|
||||
|
||||
The new `/docs/deal-pulse` explainer page (shipped this wave, §7.1)
|
||||
should also be kept in sync with the signal set.
|
||||
|
||||
---
|
||||
|
||||
## 4. Suggested fix wave (decisions needed from Matt)
|
||||
|
||||
Per the doc structure, these are the punch-list items in priority order:
|
||||
|
||||
1. **Ship the positive signals (eoi_sent, deposit_received, contract_signed).**
|
||||
Biggest visible win. ~1.5h.
|
||||
2. **Ship the rejection / risk signals (eoi_rejected, reservation_cancelled, berth_sold_to_other).**
|
||||
Pairs naturally with the §4.13 rejection cascade we shipped this
|
||||
wave. ~2h.
|
||||
3. **Ship the cadence escalation (eoi_awaiting / contract_awaiting laddered scoring).**
|
||||
~30 min.
|
||||
4. **Decide on the external-signed-paths gating question.**
|
||||
5. **Decide on the cancel-stage-rollback question.**
|
||||
|
||||
Each is small individually; combined the deal-pulse model gets meaningfully
|
||||
more accurate. Suggest bundling 1–3 into one PR for review economy.
|
||||
Reference in New Issue
Block a user