docs(plan): comprehensive 7-phase master plan for post-audit work
Single source of truth for all remaining audit + feature work:
Documenso completion, deal-pulse signals + admin config, EOI overrides,
Reminders, email-copy refactor, IMAP bounce linking, PDF editor.
Each phase carries goal, scope, schema, API/UI surfaces, acceptance
criteria, test plan, effort estimate, and a sub-task tracker that
fresh sessions tick through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:43:12 +02:00
|
|
|
|
# Master Implementation Plan — Post-Audit Remediation & Feature Expansion
|
|
|
|
|
|
|
|
|
|
|
|
**Created:** 2026-05-18
|
|
|
|
|
|
**Status:** Active — sequenced across multiple sessions
|
|
|
|
|
|
**Companion docs:**
|
|
|
|
|
|
|
|
|
|
|
|
- `POST-AUDIT-SPEC-2026-05-18.md` — original design decisions (this doc extends + supersedes)
|
|
|
|
|
|
- `AUDIT-FIX-WAVE-2026-05-18.md` — what already shipped
|
|
|
|
|
|
- `deal-pulse-trigger-audit.md` — call-site inventory for §1.2 signal expansion
|
|
|
|
|
|
- `eoi-documenso-field-mapping.md` — token → AcroForm map for §1.3 EOI overrides
|
|
|
|
|
|
- `berth-recommender-and-pdf-plan.md` — prior PDF infrastructure context
|
|
|
|
|
|
|
|
|
|
|
|
This is the single source of truth for everything outstanding. Each
|
|
|
|
|
|
phase is self-contained: a fresh session can pick up any phase and ship
|
|
|
|
|
|
it without re-reading the others. Phases are ordered by dependency +
|
|
|
|
|
|
ship-size; bigger features can be split across sessions inside their
|
|
|
|
|
|
own phase boundary.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Sequencing summary
|
|
|
|
|
|
|
|
|
|
|
|
| # | Phase | Effort | Depends on |
|
|
|
|
|
|
| --- | ---------------------------------------------------------------------------- | ------------ | -------------------------- |
|
|
|
|
|
|
| 1 | Documenso completion (7 → 2 → 5) + Supplemental form per-port | ~9–10h | none |
|
|
|
|
|
|
| 2 | Deal-pulse signal expansion + admin config UI | ~5–6h | none |
|
|
|
|
|
|
| 3 | EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) | ~1–1.5 weeks | none |
|
|
|
|
|
|
| 4 | Reminders (reminder_note + standalone tasks + per-user TOD) | ~3–4 days | none |
|
|
|
|
|
|
| 5 | §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) | ~5–7 days | requires old-CRM reference |
|
|
|
|
|
|
| 6 | M-EM03 IMAP bounce-to-interest linking | ~3–5 days | none |
|
|
|
|
|
|
| 7 | PDF template editor (Phases 1+2) | ~3–4 weeks | none |
|
|
|
|
|
|
|
|
|
|
|
|
**Total visible work:** ~7–8 weeks of focused development at 1 phase at a time.
|
|
|
|
|
|
Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are
|
|
|
|
|
|
medium; Phase 7 is the long one.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 1 — Documenso completion + Supplemental form per-port
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §3 (Supplemental form) + §4 (Documenso 2/5/7).
|
|
|
|
|
|
Bundled because both touch admin UIs under `/admin/documenso/` and `/admin/email/`.
|
|
|
|
|
|
|
|
|
|
|
|
### 1.1 Documenso Phase 7 — Project Director RBAC (~1h)
|
|
|
|
|
|
|
|
|
|
|
|
**Goal:** When a Documenso event arrives for the developer or approver
|
|
|
|
|
|
signer, also notify the linked CRM user in their inbox.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- Add "Linked to CRM user" dropdowns to `/admin/documenso/page.tsx` for
|
|
|
|
|
|
the existing `developer_user_id` and `approver_user_id` system_settings.
|
|
|
|
|
|
- Auto-fill name/email when a user is selected (read via existing
|
|
|
|
|
|
`/api/v1/admin/users/picker`).
|
|
|
|
|
|
- Webhook handler additions in `src/app/api/webhooks/documenso/route.ts`:
|
|
|
|
|
|
when an event matches the developer/approver, also emit a
|
|
|
|
|
|
`documenso:signed` notification routed to the linked CRM user.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:**
|
|
|
|
|
|
|
|
|
|
|
|
- Permissions changes (using existing notification routing).
|
|
|
|
|
|
- New audit*log actions (existing `documenso_webhook*\*` covers it).
|
|
|
|
|
|
|
|
|
|
|
|
**Data:** No schema change. `system_settings.developer_user_id` and
|
|
|
|
|
|
`approver_user_id` already exist.
|
|
|
|
|
|
|
|
|
|
|
|
**API:** No new routes. Reuses `/api/v1/admin/users/picker`.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** Two new fields in the Documenso admin page (left column,
|
|
|
|
|
|
below the existing developer name/email pair).
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- Selecting a CRM user fills the name + email fields automatically.
|
|
|
|
|
|
- Test webhook fires: linked user sees a notification in their inbox.
|
|
|
|
|
|
- Unlink (select "None"): no notification fires.
|
|
|
|
|
|
|
|
|
|
|
|
**Test plan:**
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: webhook router resolves user_id → notification target.
|
|
|
|
|
|
- E2E (smoke): admin can link/unlink users; UI updates persist.
|
|
|
|
|
|
|
|
|
|
|
|
### 1.2 Documenso Phase 2 — Webhook handler enhancement (~3–4h)
|
|
|
|
|
|
|
|
|
|
|
|
**Goal:** Sequential signing fires "your turn" emails to signer N+1
|
|
|
|
|
|
when signer N completes; on COMPLETED, distribute signed PDF to all CC
|
|
|
|
|
|
emails; tighten idempotency.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- Cascading invite: in `handleDocumentSigned`, look up the next
|
|
|
|
|
|
pending recipient (next `recipientId` in order with `signed_at IS
|
|
|
|
|
|
NULL`) and queue a `sendSigningInvitation` for that signer.
|
|
|
|
|
|
Sequential mode only (check `signing_order`).
|
|
|
|
|
|
- On-completion CC distribution: in `handleDocumentCompleted`, after
|
|
|
|
|
|
the PDF is downloaded and saved to files, email each
|
|
|
|
|
|
`documents.completion_cc_emails` row with the signed PDF as a
|
|
|
|
|
|
download link (signed URL, 24h TTL).
|
|
|
|
|
|
- Token-based matching: prefer `signing_token` over email for
|
|
|
|
|
|
webhook → recipient resolution; falls back to email-only when
|
|
|
|
|
|
token is absent.
|
|
|
|
|
|
- Idempotency: composite unique constraint
|
|
|
|
|
|
`(documensoDocumentId, recipientEmail, eventType)` on `documentEvents`;
|
|
|
|
|
|
replaces the current body-hash dedup.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:**
|
|
|
|
|
|
|
|
|
|
|
|
- Parallel-mode invite flow (already covered by initial distribution).
|
|
|
|
|
|
- Self-hosted PDF attachment (link-only — keeps emails light, see
|
|
|
|
|
|
CLAUDE.md note on email_attach_threshold_mb).
|
|
|
|
|
|
|
|
|
|
|
|
**Data:**
|
|
|
|
|
|
|
|
|
|
|
|
- Migration: drop body-hash unique index on `documentEvents`, add
|
|
|
|
|
|
`unique(documensoDocumentId, recipientEmail, eventType)`. Migration
|
|
|
|
|
|
is reversible — the body-hash column stays.
|
|
|
|
|
|
|
|
|
|
|
|
**API:** No new routes. Internal webhook handler only.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** No change.
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- Sequential 3-signer doc: signer 1 signs → signer 2 receives invite
|
|
|
|
|
|
email; signer 2 signs → signer 3 receives invite; signer 3 signs →
|
|
|
|
|
|
COMPLETED fires and CC list gets the signed PDF link.
|
|
|
|
|
|
- Duplicate webhook retries are no-ops (composite key blocks insert).
|
|
|
|
|
|
- Parallel-mode doc: no cascade (all signers got their invite at send).
|
|
|
|
|
|
|
|
|
|
|
|
**Test plan:**
|
|
|
|
|
|
|
|
|
|
|
|
- Integration: mock 3-signer sequential webhook stream, assert email
|
|
|
|
|
|
count + distribution.
|
|
|
|
|
|
- Integration: COMPLETED webhook with CC list, assert link email per CC.
|
|
|
|
|
|
- Unit: idempotency composite key rejects duplicates.
|
|
|
|
|
|
|
|
|
|
|
|
### 1.3 Documenso Phase 5 — Embedded signing URL verification (~1–2h)
|
|
|
|
|
|
|
|
|
|
|
|
**Goal:** Confirm the marketing site's `/sign/<type>/<token>` route
|
|
|
|
|
|
handles every signer-role × documentType combo; tighten role-specific
|
|
|
|
|
|
copy in invitation emails.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- Audit `signerMessages` map in
|
|
|
|
|
|
`src/lib/email/templates/signing-invitation.ts` — fill gaps for
|
|
|
|
|
|
every (role, documentType) pair currently in production.
|
|
|
|
|
|
- nginx CORS block: constrain Documenso webhook receiver origin
|
|
|
|
|
|
(config-only, no code change).
|
|
|
|
|
|
- Manual verification pass: walk through `/sign/eoi/<token>`,
|
|
|
|
|
|
`/sign/contract/<token>`, `/sign/reservation/<token>` for each
|
|
|
|
|
|
signer role (client / approver / developer). Document missing
|
|
|
|
|
|
states in a quick checklist.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:**
|
|
|
|
|
|
|
|
|
|
|
|
- New embed surfaces (current routes are sufficient).
|
|
|
|
|
|
- CSP changes (handled in `src/proxy.ts` already).
|
|
|
|
|
|
|
|
|
|
|
|
**Data:** None.
|
|
|
|
|
|
|
|
|
|
|
|
**API:** None.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** Copy-only changes in invitation email body.
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- Each role × doc-type combo renders the correct welcome copy.
|
|
|
|
|
|
- nginx config blocks unknown origins on the webhook receiver
|
|
|
|
|
|
(verified by curl from a non-Documenso IP).
|
|
|
|
|
|
|
|
|
|
|
|
**Test plan:**
|
|
|
|
|
|
|
|
|
|
|
|
- Snapshot tests on email template rendering for each
|
|
|
|
|
|
(role, documentType) tuple.
|
|
|
|
|
|
- Manual walkthrough checklist landed in PR description.
|
|
|
|
|
|
|
|
|
|
|
|
### 1.4 Supplemental form per-port setting (~2h)
|
|
|
|
|
|
|
|
|
|
|
|
**Goal:** "Send supplemental info form" link in auto-emails resolves
|
|
|
|
|
|
to a marketing-site URL when configured per-port; falls back to the
|
|
|
|
|
|
CRM-hosted `/supplemental/[token]` route otherwise.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- New `system_settings.supplemental_form_url` key (per-port, optional,
|
|
|
|
|
|
text). Schema already supports arbitrary keys.
|
|
|
|
|
|
- Email link generator in `src/lib/services/sales-emails.ts` (or
|
|
|
|
|
|
wherever the supplemental-info email is composed):
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const url = cfg.supplementalFormUrl
|
|
|
|
|
|
? `${cfg.supplementalFormUrl}?token=${raw}`
|
|
|
|
|
|
: `${env.APP_URL}/supplemental/${raw}`;
|
|
|
|
|
|
```
|
|
|
|
|
|
- Admin UI: add the field to `src/app/(dashboard)/[portSlug]/admin/email/page.tsx`
|
|
|
|
|
|
as a single text input with help hint "Leave blank to use the
|
|
|
|
|
|
built-in CRM page."
|
|
|
|
|
|
- CRM fallback route `/supplemental/[token]/page.tsx`: confirm it still
|
|
|
|
|
|
renders (already exists). Add dual-mode "If you don't see your
|
|
|
|
|
|
details, contact your rep" hint.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:** Token format changes; the existing token scheme works
|
|
|
|
|
|
for both modes.
|
|
|
|
|
|
|
|
|
|
|
|
**Data:** New system_settings key only.
|
|
|
|
|
|
|
|
|
|
|
|
**API:** No new routes.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** One new input on `/admin/email/page.tsx`.
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- With URL configured: email link points at marketing site with token
|
|
|
|
|
|
query param.
|
|
|
|
|
|
- With URL blank: email link points at CRM route.
|
|
|
|
|
|
- Token roundtrips through both modes successfully.
|
|
|
|
|
|
|
|
|
|
|
|
**Test plan:**
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: link resolver returns expected URL for both cases.
|
|
|
|
|
|
- Integration: send-out flow with each config variant.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 1 total effort
|
|
|
|
|
|
|
|
|
|
|
|
~9–10 hours. Ships as 4 commits in a single PR.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 2 — Deal-pulse signal expansion + admin config UI
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** `deal-pulse-trigger-audit.md` (call-site inventory).
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
The deal-pulse chip currently shows momentum (stage advancement,
|
|
|
|
|
|
time-in-stage) but ignores high-value pipeline signals. Expand the
|
|
|
|
|
|
signal set + give admins per-port control over which signals fire,
|
|
|
|
|
|
what labels they show, and tier thresholds.
|
|
|
|
|
|
|
|
|
|
|
|
### Signal additions
|
|
|
|
|
|
|
|
|
|
|
|
**Positive (brighten chip):**
|
|
|
|
|
|
|
|
|
|
|
|
- `eoi_sent` — fires when EOI status transitions to `sent`.
|
|
|
|
|
|
Already a call-site in `documents.service.ts` for stage auto-advance;
|
|
|
|
|
|
hook a `pulseSignals.push({ kind: 'eoi_sent', at: now })` next to it.
|
|
|
|
|
|
- `deposit_received` — fires when an invoice with `purpose = 'deposit'`
|
|
|
|
|
|
flips to `status = 'paid'`. Hook in `invoices.service.ts:markPaid`.
|
|
|
|
|
|
- `contract_signed` — fires when a `documents` row with
|
|
|
|
|
|
`templateType = 'contract'` flips to `status = 'completed'`. Hook in
|
|
|
|
|
|
webhook handler `handleDocumentCompleted`.
|
|
|
|
|
|
|
|
|
|
|
|
**Negative (darken chip):**
|
|
|
|
|
|
|
|
|
|
|
|
- `document_declined` — fires when any doc on the interest flips to
|
|
|
|
|
|
`status IN ('declined', 'rejected')`. Hook in
|
|
|
|
|
|
`handleDocumentRejected` (already exists from Phase A).
|
|
|
|
|
|
- `reservation_cancelled` — fires when a `reservations` row flips to
|
|
|
|
|
|
`status = 'cancelled'`. Hook in `reservations.service.ts`.
|
|
|
|
|
|
- `berth_sold_to_other` — fires when the interest's primary berth gets
|
|
|
|
|
|
linked to a different completed interest. Hook in
|
|
|
|
|
|
`interest-berths.service.ts:upsertInterestBerth` when the conflicting
|
|
|
|
|
|
link is detected.
|
|
|
|
|
|
|
|
|
|
|
|
**Cadence tiers:**
|
|
|
|
|
|
|
|
|
|
|
|
- Today: stale flag fires at >7 days in same stage.
|
|
|
|
|
|
- New: tier system reading per-port thresholds:
|
|
|
|
|
|
- `pulse_cadence_warning_days` (default 7) → "Quiet"
|
|
|
|
|
|
- `pulse_cadence_critical_days` (default 21) → "At Risk"
|
|
|
|
|
|
- `pulse_cadence_terminal_days` (default 45) → "Critical"
|
|
|
|
|
|
|
|
|
|
|
|
### Admin config UI
|
|
|
|
|
|
|
|
|
|
|
|
New page `/admin/pulse/page.tsx` (or subsection of `/admin/sales/`):
|
|
|
|
|
|
|
|
|
|
|
|
1. **Master toggle** (`pulse_enabled`, default `true`): off → chip hides
|
|
|
|
|
|
on every interest list/detail surface.
|
|
|
|
|
|
2. **Per-signal toggles** — checkbox per signal, all default on. Stored
|
|
|
|
|
|
as `pulse_signal_<name>_enabled`.
|
|
|
|
|
|
3. **Label rename map** — `pulse_label_<key>` text fields for: "Hot",
|
|
|
|
|
|
"Quiet", "At Risk", "Critical", "EOI sent", "Deposit paid",
|
|
|
|
|
|
"Contract signed", "Declined", "Reservation cancelled",
|
|
|
|
|
|
"Berth resold". Empty value = use built-in default.
|
|
|
|
|
|
4. **Cadence threshold inputs** — three numeric inputs for the day
|
|
|
|
|
|
thresholds above.
|
|
|
|
|
|
5. **Weight tuning** — already partially exists as `heat_weight_*` keys.
|
|
|
|
|
|
Move into this page as a sub-section.
|
|
|
|
|
|
|
|
|
|
|
|
### Schema additions
|
|
|
|
|
|
|
|
|
|
|
|
- New system_settings keys (per-port, all optional, all read with
|
|
|
|
|
|
defaults):
|
|
|
|
|
|
- `pulse_enabled boolean default true`
|
|
|
|
|
|
- `pulse_signal_<name>_enabled boolean default true` for each signal
|
|
|
|
|
|
- `pulse_label_<key> text` for each renamable label
|
|
|
|
|
|
- `pulse_cadence_warning_days int default 7`
|
|
|
|
|
|
- `pulse_cadence_critical_days int default 21`
|
|
|
|
|
|
- `pulse_cadence_terminal_days int default 45`
|
|
|
|
|
|
|
|
|
|
|
|
No new tables. The pulse signal computation is read-time from existing
|
|
|
|
|
|
data (`documents`, `invoices`, `reservations`, `interest_berths`) — no
|
|
|
|
|
|
persistent signal log needed.
|
|
|
|
|
|
|
|
|
|
|
|
### API additions
|
|
|
|
|
|
|
|
|
|
|
|
- `GET /api/v1/admin/pulse/settings` — read current config.
|
|
|
|
|
|
- `PUT /api/v1/admin/pulse/settings` — write config (Zod-validated).
|
|
|
|
|
|
|
|
|
|
|
|
Existing pulse computation in `src/lib/services/deal-pulse.service.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
- Extend `computePulseFor(interestId)` to read per-port settings + new
|
|
|
|
|
|
signal sources.
|
|
|
|
|
|
- Cache settings per-port for the request lifetime.
|
|
|
|
|
|
|
|
|
|
|
|
### UI
|
|
|
|
|
|
|
|
|
|
|
|
- `<DealPulseChip>` already exists; extend label resolution to use
|
|
|
|
|
|
per-port custom labels with fallback to built-ins.
|
|
|
|
|
|
- New admin page (1 file, ~250 LOC).
|
|
|
|
|
|
|
|
|
|
|
|
### Acceptance
|
|
|
|
|
|
|
|
|
|
|
|
- Each signal fires when the linked event happens (verified via
|
|
|
|
|
|
integration test triggering the upstream event).
|
|
|
|
|
|
- Master toggle off → chip absent on every surface.
|
|
|
|
|
|
- Per-signal toggle off → signal absent from chip even when event
|
|
|
|
|
|
fires.
|
|
|
|
|
|
- Custom label "Hot" → "Active" renders correctly.
|
|
|
|
|
|
- Cadence threshold 7 → 14 → 30 → tier transitions match the new
|
|
|
|
|
|
thresholds.
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- Unit per signal: trigger upstream event, assert pulse output contains
|
|
|
|
|
|
the signal.
|
|
|
|
|
|
- Unit cadence tier: insert interests with stage-entered timestamps
|
|
|
|
|
|
at boundary ages, assert tier classification.
|
|
|
|
|
|
- Integration: admin page round-trips config save + read.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort
|
|
|
|
|
|
|
|
|
|
|
|
~5–6h. One PR.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 3 — EOI field overrides
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §1 (base spec) + user
|
|
|
|
|
|
clarifications below.
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
When generating an EOI, rep can override pre-filled fields (contact
|
|
|
|
|
|
info, addresses, yacht name + dimensions) from a dropdown of every
|
|
|
|
|
|
known value for that channel. Manual entries persist as tracked
|
|
|
|
|
|
secondary values; future EOIs can pick them from the dropdown. Yacht
|
|
|
|
|
|
overrides spawn a new yacht record linked to the same interest/client.
|
|
|
|
|
|
|
|
|
|
|
|
### User clarifications captured
|
|
|
|
|
|
|
|
|
|
|
|
1. **Multi-value contacts:** Email and phone fields render as
|
|
|
|
|
|
dropdowns of every `client_contacts` row for that channel.
|
|
|
|
|
|
2. **Per-EOI vs persistent override:**
|
|
|
|
|
|
- "Use only for this EOI" → write to `documents.override_*` cols,
|
|
|
|
|
|
don't touch `client_contacts`.
|
|
|
|
|
|
- "Save as new contact" → insert `client_contacts` row with
|
|
|
|
|
|
`is_primary=false`, `source='eoi-custom-input'`.
|
|
|
|
|
|
- "Set as default for future documents" → promote to
|
|
|
|
|
|
`is_primary=true`, demote the prior primary.
|
|
|
|
|
|
3. **Badge label:** Use `[EOI]` not `[EOI Only]` (future docs may
|
|
|
|
|
|
reuse the value).
|
|
|
|
|
|
4. **Yacht spawn:** EOI form's yacht-name field has a "+ New yacht"
|
|
|
|
|
|
button → inline modal opens with the existing `<YachtForm>` —
|
|
|
|
|
|
on save, new yacht linked to same client/interest, tagged with
|
|
|
|
|
|
`yachts.source = 'eoi-generated'`. The current EOI uses the new
|
|
|
|
|
|
yacht. Original yacht untouched.
|
|
|
|
|
|
|
|
|
|
|
|
### Schema additions
|
|
|
|
|
|
|
|
|
|
|
|
- `client_contacts.source text default 'manual'` — values:
|
|
|
|
|
|
`'manual' | 'imported' | 'eoi-custom-input'`.
|
|
|
|
|
|
- `client_contacts.source_document_id text references documents(id) on delete set null`.
|
|
|
|
|
|
- `client_addresses.source` + `source_document_id` (mirror).
|
|
|
|
|
|
- `yachts.source text default 'manual'` — values:
|
|
|
|
|
|
`'manual' | 'imported' | 'eoi-generated'`.
|
|
|
|
|
|
- `yachts.source_document_id text references documents(id) on delete set null`.
|
|
|
|
|
|
- New audit_actions enum entries: `eoi_field_override`,
|
|
|
|
|
|
`promote_to_primary`, `eoi_spawn_yacht`.
|
|
|
|
|
|
- New `documents.override_*` columns (nullable):
|
|
|
|
|
|
`override_client_email`, `override_client_phone`, `override_client_address_line_*`,
|
|
|
|
|
|
etc. — per the field map in `eoi-documenso-field-mapping.md`.
|
|
|
|
|
|
|
|
|
|
|
|
### API additions
|
|
|
|
|
|
|
|
|
|
|
|
- `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary` —
|
|
|
|
|
|
promotes a non-primary contact, demotes the prior primary.
|
|
|
|
|
|
- `POST /api/v1/yachts` extension: accepts `source` + `source_document_id`
|
|
|
|
|
|
fields (admin-only or system-only).
|
|
|
|
|
|
- EOI generate endpoint
|
|
|
|
|
|
(`/api/v1/document-templates/[id]/generate-and-sign`) accepts
|
|
|
|
|
|
per-field override params; persists to `documents.override_*` cols
|
|
|
|
|
|
or spawns `client_contacts` rows depending on the toggle state.
|
|
|
|
|
|
|
|
|
|
|
|
### UI surface
|
|
|
|
|
|
|
|
|
|
|
|
- `<EoiGenerateDialog>` — each editable field becomes a `<Combobox>`
|
|
|
|
|
|
with the multi-value list + a "Save as new …" inline action.
|
|
|
|
|
|
- Below each field: two checkboxes:
|
|
|
|
|
|
- `[ ] Use only for this EOI` (default off)
|
|
|
|
|
|
- `[ ] Set as default for future docs` (default off)
|
|
|
|
|
|
- Client + Yacht detail pages: `[EOI]` badge on non-primary
|
|
|
|
|
|
rows; "Set as primary" action on each row.
|
|
|
|
|
|
- Yacht spawn: "+ New yacht" button next to yacht dropdown opens
|
|
|
|
|
|
Sheet (`<Sheet side="right">`, per CLAUDE.md doctrine) with the
|
|
|
|
|
|
existing `<YachtForm>`. On save, new yacht is preselected.
|
|
|
|
|
|
|
|
|
|
|
|
### Acceptance
|
|
|
|
|
|
|
|
|
|
|
|
- Multi-email client: EOI dropdown shows all emails; rep picks the
|
|
|
|
|
|
secondary; EOI uses it.
|
|
|
|
|
|
- "Save as new contact" creates a `client_contacts` row visible in
|
|
|
|
|
|
the client detail panel.
|
|
|
|
|
|
- "Set as default" promotes to primary and demotes the prior.
|
|
|
|
|
|
- Yacht spawn: new yacht visible under both client and interest
|
|
|
|
|
|
with the `[EOI]` badge; original yacht unchanged.
|
|
|
|
|
|
- Audit log records each override action with the source doc id.
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- Unit per scenario: per-EOI override, save-as-new, promote-to-primary,
|
|
|
|
|
|
yacht spawn.
|
|
|
|
|
|
- Integration: full EOI generate flow with overrides, assert resulting
|
|
|
|
|
|
doc + side-effects.
|
|
|
|
|
|
- E2E (smoke): rep generates EOI with a custom email + new yacht;
|
|
|
|
|
|
artifacts visible on detail pages.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort breakdown (across sessions)
|
|
|
|
|
|
|
|
|
|
|
|
- **Session 3a (~3 days):** Schema migration + audit_actions + API endpoints
|
|
|
|
|
|
for contact promote + document override persistence. Tests for service layer.
|
|
|
|
|
|
- **Session 3b (~3 days):** UI — EOI form dropdowns, "save as new"
|
|
|
|
|
|
inline flow, "set as default" toggle, badges on client/yacht detail.
|
|
|
|
|
|
- **Session 3c (~2 days):** Yacht spawn flow — Sheet + YachtForm
|
|
|
|
|
|
reuse + interest auto-link. Integration tests + E2E smoke.
|
|
|
|
|
|
- **Session 3d (~1–2 days):** Polish — audit log surfacing in
|
|
|
|
|
|
audit-log UI, badges/labels in notification copy, documentation.
|
|
|
|
|
|
|
|
|
|
|
|
### Risks
|
|
|
|
|
|
|
|
|
|
|
|
- Schema migration is FK-heavy. Run `pnpm db:generate` carefully;
|
|
|
|
|
|
the partial unique index on `client_contacts.is_primary` (one primary
|
|
|
|
|
|
per channel) must not be broken by the promote endpoint.
|
|
|
|
|
|
- The promote endpoint is a two-step write that must be transactional
|
|
|
|
|
|
(demote prior primary, then promote target) — wrap in
|
|
|
|
|
|
`db.transaction`.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort
|
|
|
|
|
|
|
|
|
|
|
|
~1–1.5 weeks. Split into 4 sub-sessions per the breakdown above.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 4 — Reminders
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §2.
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
Reps can: (a) attach a follow-up note to interest cadences, (b) create
|
|
|
|
|
|
standalone tasks not tied to an entity, (c) assign tasks to other reps,
|
|
|
|
|
|
(d) configure their default firing time-of-day with per-row override.
|
|
|
|
|
|
|
|
|
|
|
|
### Schema additions
|
|
|
|
|
|
|
|
|
|
|
|
- `interests.reminder_note text NULL` — surfaced in notification body
|
|
|
|
|
|
and inbox row.
|
|
|
|
|
|
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`.
|
|
|
|
|
|
- `reminders.fired_at timestamptz NULL` — drives worker idempotency.
|
|
|
|
|
|
- No new tables. `reminders` table already has title, note, priority,
|
|
|
|
|
|
due_at, assigned_to, snoozed_until, google_calendar_event_id.
|
|
|
|
|
|
|
|
|
|
|
|
### API additions
|
|
|
|
|
|
|
|
|
|
|
|
- `POST /api/v1/reminders` — extend to accept null `linked_entity` for
|
|
|
|
|
|
standalone tasks.
|
|
|
|
|
|
- `PATCH /api/v1/me/profile` — extend to accept `digest_time_of_day`.
|
|
|
|
|
|
- `GET /api/v1/reminders/inbox` — filter `[Mine | All my port]` toggle.
|
|
|
|
|
|
|
|
|
|
|
|
### UI surface
|
|
|
|
|
|
|
|
|
|
|
|
- New shared component `<CreateReminderDialog>` — Title (required),
|
|
|
|
|
|
Note (optional), Due date+time (defaults to user's TOD),
|
|
|
|
|
|
Priority dropdown, Assign-to picker (default = current user),
|
|
|
|
|
|
Linked entity dropdown (only visible from inbox surface; locked
|
|
|
|
|
|
on per-entity surface).
|
|
|
|
|
|
- `<RemindersInbox>`: `[+ New task]` button → dialog.
|
|
|
|
|
|
- Interest / client / berth / yacht detail pages: existing Reminders
|
|
|
|
|
|
section gains `[+ Task]` button → dialog (linked entity pre-filled).
|
|
|
|
|
|
- Settings page: time picker for "default reminder time".
|
|
|
|
|
|
|
|
|
|
|
|
### Worker scheduler
|
|
|
|
|
|
|
|
|
|
|
|
- 15-min cron tick scans `reminders WHERE due_at <= now() AND fired_at IS NULL`,
|
|
|
|
|
|
fires the notification, sets `fired_at`. Wrap in `pg_advisory_xact_lock`
|
|
|
|
|
|
per-port to avoid duplicate fires on parallel workers.
|
|
|
|
|
|
|
|
|
|
|
|
### Acceptance
|
|
|
|
|
|
|
|
|
|
|
|
- Standalone task: created from inbox, no linked entity, fires at
|
|
|
|
|
|
the chosen TOD.
|
|
|
|
|
|
- Per-interest cadence with note: surfaces in notification body +
|
|
|
|
|
|
inbox row.
|
|
|
|
|
|
- Assign to another rep: assignee sees task in their inbox; chip
|
|
|
|
|
|
shows the assignor's name; original creator sees an assignee chip.
|
|
|
|
|
|
- Default TOD set to 14:00 → new reminders default to 14:00; per-row
|
|
|
|
|
|
override to 09:30 wins.
|
|
|
|
|
|
- Worker idempotency: same reminder fires once even if 2 worker
|
|
|
|
|
|
processes race.
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: cron scan picks up due reminders; fired_at gates re-firing.
|
|
|
|
|
|
- Integration: dialog → POST → DB row visible in inbox.
|
|
|
|
|
|
- E2E: rep creates standalone task from inbox; appears for assignee.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort
|
|
|
|
|
|
|
|
|
|
|
|
~3–4 days. One PR.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 5 — §11.3 Email-copy refactor
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** old-CRM Nuxt repo at
|
|
|
|
|
|
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
|
|
|
|
|
|
(notable: `server/utils/email.ts`, `server/tasks/process-sales-emails.ts`,
|
|
|
|
|
|
`components/EmailComposer.vue`).
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
Modernize email tone to luxury-port voice; audit per-port branding
|
|
|
|
|
|
chain (logo, signature block, footer copy); ensure every automated
|
|
|
|
|
|
email uses the per-port branded shell.
|
|
|
|
|
|
|
|
|
|
|
|
### Scope in
|
|
|
|
|
|
|
|
|
|
|
|
- **Tone pass:** Rewrite every template in `src/lib/email/templates/`
|
|
|
|
|
|
using the old-CRM templates as voice reference (open them, capture
|
|
|
|
|
|
cadence + phrasing, port to current React-email or HTML-string
|
|
|
|
|
|
format).
|
|
|
|
|
|
- **Branding chain audit:** Walk every sender callsite (`sendEmail` in
|
|
|
|
|
|
`src/lib/email/`), confirm port-specific logo URL and footer get
|
|
|
|
|
|
threaded through via `cfg.portLogoUrl`, `cfg.portFooter`. Fix any
|
|
|
|
|
|
hard-coded `s3.portnimara.com/logo.png` strings (current templates
|
|
|
|
|
|
reference this directly per CLAUDE.md note).
|
|
|
|
|
|
- **New templates if missing:** signing-invitation cascade, supplemental
|
|
|
|
|
|
form, reminder digest. Match the existing tone after the rewrite.
|
|
|
|
|
|
|
|
|
|
|
|
### Scope out
|
|
|
|
|
|
|
|
|
|
|
|
- Localization. Current templates are EN-only; defer i18n to Phase C
|
|
|
|
|
|
unless a port specifically requests another language.
|
|
|
|
|
|
- New triggers. Same set of emails as today, better copy + branding.
|
|
|
|
|
|
|
|
|
|
|
|
### Data
|
|
|
|
|
|
|
|
|
|
|
|
None. Settings keys for branding already exist (`port_logo_url`,
|
|
|
|
|
|
`port_email_footer`).
|
|
|
|
|
|
|
|
|
|
|
|
### API
|
|
|
|
|
|
|
|
|
|
|
|
None.
|
|
|
|
|
|
|
|
|
|
|
|
### UI
|
|
|
|
|
|
|
|
|
|
|
|
None (admin email panel already exposes the settings keys).
|
|
|
|
|
|
|
|
|
|
|
|
### Acceptance
|
|
|
|
|
|
|
|
|
|
|
|
- Each template renders correctly for port-nimara AND a 2nd test port
|
|
|
|
|
|
with different logo + footer.
|
|
|
|
|
|
- Old-CRM reference quotes inline in PR description for traceability.
|
|
|
|
|
|
- No remaining hard-coded port branding strings (grep `portnimara.com`,
|
|
|
|
|
|
expect zero matches outside settings defaults).
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- Snapshot tests per template at the rendering layer.
|
|
|
|
|
|
- Manual: send a test email per template to a real inbox, confirm
|
|
|
|
|
|
branding renders.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort
|
|
|
|
|
|
|
|
|
|
|
|
~5–7 days. The grunt is the tone rewrite (each template needs
|
|
|
|
|
|
~30–45 min of focused copywriting + review). One PR with the
|
|
|
|
|
|
template files; CI snapshot tests gate it.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 6 — M-EM03 IMAP bounce-to-interest linking
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** Phase 7 §14.9 in the original system spec.
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
When an outbound sales email bounces (NDR returned via IMAP), match
|
|
|
|
|
|
the bounce to the originating `document_sends` row and surface a
|
|
|
|
|
|
warning on the linked interest's email tab.
|
|
|
|
|
|
|
|
|
|
|
|
### Scope in
|
|
|
|
|
|
|
|
|
|
|
|
- Bounce parser in `src/lib/email/bounce-parser.ts` — extract original
|
|
|
|
|
|
recipient + bounce reason from common NDR formats (Gmail, Outlook,
|
|
|
|
|
|
Postfix, Exchange).
|
|
|
|
|
|
- Cron job in `src/jobs/processors/imap-bounce-poller.ts` — polls
|
|
|
|
|
|
configured `IMAP_*` mailbox for new bounces, matches against
|
|
|
|
|
|
`document_sends.recipient_email + sent_at`, updates
|
|
|
|
|
|
`document_sends.bounce_status` + `bounce_reason`.
|
|
|
|
|
|
- UI surface on interest's Emails tab: red banner + reason inline
|
|
|
|
|
|
with the bounced send row.
|
|
|
|
|
|
- Notification: rep gets an in-CRM notification when one of their
|
|
|
|
|
|
sends bounces.
|
|
|
|
|
|
|
|
|
|
|
|
### Scope out
|
|
|
|
|
|
|
|
|
|
|
|
- Auto-resending to corrected addresses (manual rep action).
|
|
|
|
|
|
- Out-of-office detection (different signal; defer).
|
|
|
|
|
|
|
|
|
|
|
|
### Data
|
|
|
|
|
|
|
|
|
|
|
|
- `document_sends.bounce_status text NULL` — values: `'hard'`, `'soft'`, `'ooo'`, `null`.
|
|
|
|
|
|
- `document_sends.bounce_reason text NULL`.
|
|
|
|
|
|
- `document_sends.bounce_detected_at timestamptz NULL`.
|
|
|
|
|
|
|
|
|
|
|
|
### API
|
|
|
|
|
|
|
|
|
|
|
|
- No external routes. Internal cron only.
|
|
|
|
|
|
- Existing `GET /api/v1/interests/:id/emails` projection extends to
|
|
|
|
|
|
surface bounce fields.
|
|
|
|
|
|
|
|
|
|
|
|
### UI
|
|
|
|
|
|
|
|
|
|
|
|
- `<InterestEmailsTab>` row gets a red border + "Bounced: <reason>"
|
|
|
|
|
|
banner when `bounce_status IS NOT NULL`.
|
|
|
|
|
|
- Notification bell entry: "Email to X bounced — check the interest".
|
|
|
|
|
|
|
|
|
|
|
|
### Acceptance
|
|
|
|
|
|
|
|
|
|
|
|
- Send 1 real bounced email to a test IMAP mailbox; cron picks it up
|
|
|
|
|
|
within 15 min; UI shows the bounce; notification fires.
|
|
|
|
|
|
- Soft bounce (OOO) vs hard bounce surface differently.
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: parser against fixture NDRs from each provider.
|
|
|
|
|
|
- Integration: cron + DB update path.
|
|
|
|
|
|
- Manual: real bounce round-trip in dev with `EMAIL_REDIRECT_TO` off.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort
|
|
|
|
|
|
|
|
|
|
|
|
~3–5 days. Parser fixtures are the longest tail.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase 7 — PDF template editor (Phases 1+2)
|
|
|
|
|
|
|
|
|
|
|
|
**Reference:** `berth-recommender-and-pdf-plan.md` (background +
|
|
|
|
|
|
infrastructure context).
|
|
|
|
|
|
|
|
|
|
|
|
### Goal
|
|
|
|
|
|
|
|
|
|
|
|
A web-based PDF template editor that lets admins:
|
|
|
|
|
|
|
|
|
|
|
|
- Phase 1: View an existing PDF template, click on a page region to
|
|
|
|
|
|
drop a merge-field marker, save the field map.
|
|
|
|
|
|
- Phase 2: Edit existing fields (move, resize, delete), upload a new
|
|
|
|
|
|
PDF (replacing the source), live-preview the AcroForm fill.
|
|
|
|
|
|
|
|
|
|
|
|
This replaces the current "edit the template PDF in Acrobat, re-upload"
|
|
|
|
|
|
workflow with an in-app editor.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 7.1 — Read + place (Phase 1, ~2 weeks)
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- New admin page `/admin/templates/[id]/editor/page.tsx`.
|
|
|
|
|
|
- PDF viewer using `react-pdf` (already in deps for invoice rendering).
|
|
|
|
|
|
- Field marker overlay: click on a region → enter merge-field token
|
|
|
|
|
|
name → marker persists in `document_templates.field_map JSONB`.
|
|
|
|
|
|
- Token autocomplete from `VALID_MERGE_TOKENS`
|
|
|
|
|
|
(`src/lib/templates/merge-fields.ts`).
|
|
|
|
|
|
- Save endpoint: `PUT /api/v1/document-templates/:id/field-map`.
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:**
|
|
|
|
|
|
|
|
|
|
|
|
- Editing existing AcroForm fields (separate workflow).
|
|
|
|
|
|
- Multi-page navigation (Phase 1 = page 1 only).
|
|
|
|
|
|
- Conditional fields, signatures, repeating sections.
|
|
|
|
|
|
|
|
|
|
|
|
**Data:**
|
|
|
|
|
|
|
|
|
|
|
|
- `document_templates.field_map JSONB NULL` —
|
|
|
|
|
|
`Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>`.
|
|
|
|
|
|
|
|
|
|
|
|
**API:**
|
|
|
|
|
|
|
|
|
|
|
|
- `PUT /api/v1/document-templates/:id/field-map`.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** Full editor page; uses Sheet for token picker side-panel.
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- Admin places 3 markers on a sample PDF; saves; reopens; markers
|
|
|
|
|
|
persist at the right coords.
|
|
|
|
|
|
- Generating a doc from the template fills the AcroForm at those
|
|
|
|
|
|
coords with the merge-field values.
|
|
|
|
|
|
|
|
|
|
|
|
**Effort:** ~2 weeks.
|
|
|
|
|
|
|
|
|
|
|
|
### Phase 7.2 — Edit + preview (Phase 2, ~1–2 weeks)
|
|
|
|
|
|
|
|
|
|
|
|
**Scope in:**
|
|
|
|
|
|
|
|
|
|
|
|
- Drag-to-move existing markers.
|
|
|
|
|
|
- Drag-corner-to-resize markers.
|
|
|
|
|
|
- Delete marker via right-click → "Remove field".
|
|
|
|
|
|
- Live preview pane (right side) showing the AcroForm fill with
|
|
|
|
|
|
sample data from a chosen interest.
|
|
|
|
|
|
- Multi-page navigation (page picker top-left).
|
|
|
|
|
|
- New-PDF upload: replace the source file while preserving the
|
|
|
|
|
|
field map (warn if coord ranges shift).
|
|
|
|
|
|
|
|
|
|
|
|
**Scope out:**
|
|
|
|
|
|
|
|
|
|
|
|
- Conditional fields / signatures (defer to Phase 3, unscoped).
|
|
|
|
|
|
|
|
|
|
|
|
**Data:** No new schema; reuses `field_map` JSONB.
|
|
|
|
|
|
|
|
|
|
|
|
**API:**
|
|
|
|
|
|
|
|
|
|
|
|
- `POST /api/v1/document-templates/:id/preview` — accepts an
|
|
|
|
|
|
interest ID, returns a presigned URL to a transient preview PDF.
|
|
|
|
|
|
|
|
|
|
|
|
**UI:** Editor extends — page picker, preview pane, drag handlers,
|
|
|
|
|
|
right-click context menu.
|
|
|
|
|
|
|
|
|
|
|
|
**Acceptance:**
|
|
|
|
|
|
|
|
|
|
|
|
- Drag a marker → save → reopen → marker is at the new coords.
|
|
|
|
|
|
- Resize marker → field rendering box matches new dims.
|
|
|
|
|
|
- Upload replacement PDF → field map preserved; warning shown if
|
|
|
|
|
|
page count changed.
|
|
|
|
|
|
- Live preview reflects current field map within 2s of edits.
|
|
|
|
|
|
|
|
|
|
|
|
**Effort:** ~1–2 weeks.
|
|
|
|
|
|
|
|
|
|
|
|
### Test plan (both phases)
|
|
|
|
|
|
|
|
|
|
|
|
- Unit: field-map serialization + coord persistence.
|
|
|
|
|
|
- Integration: PUT field-map → re-GET → exact roundtrip.
|
|
|
|
|
|
- E2E: admin places marker, generates doc, signed PDF has value at
|
|
|
|
|
|
expected coord (within tolerance).
|
|
|
|
|
|
|
|
|
|
|
|
### Risks
|
|
|
|
|
|
|
|
|
|
|
|
- `react-pdf` performance on large PDFs — measure on 50-page samples
|
|
|
|
|
|
before committing to the page-picker UX.
|
|
|
|
|
|
- Coord system: PDF uses bottom-left origin; viewer uses top-left.
|
|
|
|
|
|
Wrap a single coord-converter to avoid scattered conversions.
|
|
|
|
|
|
|
|
|
|
|
|
### Effort total
|
|
|
|
|
|
|
|
|
|
|
|
~3–4 weeks for both phases. Highest cost in the plan; queue last.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Execution discipline
|
|
|
|
|
|
|
|
|
|
|
|
Each session that picks up a phase MUST:
|
|
|
|
|
|
|
|
|
|
|
|
1. **Read this doc + the referenced companion** before opening any
|
|
|
|
|
|
source file.
|
|
|
|
|
|
2. **Confirm the issue still exists** — re-grep the call sites listed
|
|
|
|
|
|
in the phase to ensure prior work hasn't already fixed something.
|
|
|
|
|
|
3. **Open a single PR per phase** unless explicitly split into
|
|
|
|
|
|
sub-sessions (Phase 3 is split into 3a/3b/3c/3d).
|
|
|
|
|
|
4. **Run all four quality gates** before reporting done:
|
|
|
|
|
|
`pnpm exec vitest run` · `pnpm tsc --noEmit` · `pnpm lint` ·
|
|
|
|
|
|
`pnpm build` (build only for changes touching middleware, env,
|
|
|
|
|
|
or build config).
|
|
|
|
|
|
5. **Update this doc** — mark the phase ☑ in the sequencing summary
|
|
|
|
|
|
table; capture any spec drift in a `## Implementation notes`
|
|
|
|
|
|
addendum at the end of the phase.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Open questions deferred to phase-start
|
|
|
|
|
|
|
|
|
|
|
|
These don't block the plan but should be resolved when the relevant
|
|
|
|
|
|
phase starts:
|
|
|
|
|
|
|
|
|
|
|
|
- **Phase 1:** Phase 5's nginx config — does the ops repo own this
|
|
|
|
|
|
file, or does this CRM repo? (Resolve before Phase 1.3.)
|
|
|
|
|
|
- **Phase 2:** Should label rename support multi-language, or is
|
|
|
|
|
|
EN-only acceptable for the per-port admin? (Recommend EN-only;
|
|
|
|
|
|
i18n is Phase C work.)
|
|
|
|
|
|
- **Phase 3:** Should `[EOI]` badges fade after a TTL or persist
|
|
|
|
|
|
forever? (Recommend forever — rep chose this label deliberately.)
|
|
|
|
|
|
- **Phase 5:** When the old-CRM tone reference is opened, capture
|
|
|
|
|
|
3–5 representative templates and quote them in the PR description
|
|
|
|
|
|
for reviewer traceability.
|
|
|
|
|
|
- **Phase 7:** Confirm `react-pdf` performance budget on the largest
|
|
|
|
|
|
template currently in production (capture page-count + LCP).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Phase ☑/☐ tracker
|
|
|
|
|
|
|
|
|
|
|
|
- ☐ Phase 1 — Documenso completion + Supplemental form
|
|
|
|
|
|
- ☐ 1.1 Documenso Phase 7 (RBAC)
|
|
|
|
|
|
- ☐ 1.2 Documenso Phase 2 (Webhook UX)
|
|
|
|
|
|
- ☐ 1.3 Documenso Phase 5 (Embedded signing)
|
|
|
|
|
|
- ☐ 1.4 Supplemental form per-port
|
|
|
|
|
|
- ☐ Phase 2 — Deal-pulse signals + admin config UI
|
|
|
|
|
|
- ☐ Phase 3 — EOI field overrides
|
|
|
|
|
|
- ☐ 3a — Schema + APIs
|
|
|
|
|
|
- ☐ 3b — EOI form UI
|
|
|
|
|
|
- ☐ 3c — Yacht spawn
|
|
|
|
|
|
- ☐ 3d — Polish + audit surfacing
|
|
|
|
|
|
- ☐ Phase 4 — Reminders
|
|
|
|
|
|
- ☐ Phase 5 — Email-copy refactor
|
|
|
|
|
|
- ☐ Phase 6 — IMAP bounce-to-interest linking
|
|
|
|
|
|
- ☐ Phase 7 — PDF template editor
|
|
|
|
|
|
- ☐ 7.1 Read + place
|
|
|
|
|
|
- ☐ 7.2 Edit + preview
|
2026-05-18 14:50:00 +02:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# Detailed Implementation Appendix
|
|
|
|
|
|
|
|
|
|
|
|
This appendix expands every phase with per-file change lists, schema
|
|
|
|
|
|
migration SQL skeletons, API request/response shapes, and component
|
|
|
|
|
|
breakdowns. Anything ambiguous in the phase summaries above is resolved
|
|
|
|
|
|
here. Read this in conjunction with the phase header.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix A — Phase 1 (Documenso completion + Supplemental form)
|
|
|
|
|
|
|
|
|
|
|
|
### A.1 — Status of each sub-phase against existing code
|
|
|
|
|
|
|
|
|
|
|
|
A grep + read pass at the time of writing this appendix confirmed:
|
|
|
|
|
|
|
|
|
|
|
|
- **1.1 Project Director RBAC notification → already in code**
|
|
|
|
|
|
(`src/lib/services/documents.service.ts:1268-1300`). Registry keys
|
|
|
|
|
|
`documenso_developer_user_id` + `documenso_approver_user_id` exist
|
|
|
|
|
|
(`src/lib/settings/registry.ts:116, 162`). Admin UI renders them via
|
|
|
|
|
|
`<RegistryDrivenForm sections={['documenso.signers']}>` with the
|
|
|
|
|
|
`user-select` field type (`registry-driven-form.tsx:499-507`).
|
|
|
|
|
|
→ **Verification only.** Smoke test by linking a CRM user on a port,
|
|
|
|
|
|
triggering a recipient-signed webhook for the matching role, and
|
|
|
|
|
|
asserting the linked user receives a `document_signing_your_turn`
|
|
|
|
|
|
notification in their inbox.
|
|
|
|
|
|
- **1.2 Cascading invite to next signer → already in code**
|
|
|
|
|
|
(`sendCascadingInviteForNextSigner` at `documents.service.ts:1220`).
|
|
|
|
|
|
→ **Verification only.** Send a 3-signer sequential EOI, sign
|
|
|
|
|
|
recipient 1, assert recipient 2 receives a branded "your turn"
|
|
|
|
|
|
email within 30s.
|
|
|
|
|
|
- **1.3 Embedded signing copy + nginx CORS → partial.** Signing
|
|
|
|
|
|
invitation copy lives in `src/lib/email/templates/` — needs a
|
|
|
|
|
|
grep for the actual file path. nginx config: confirm if owned by
|
|
|
|
|
|
this repo or the ops repo. → **Implementation needed.**
|
|
|
|
|
|
- **1.4 Supplemental form per-port URL → not started.** Existing
|
|
|
|
|
|
service at `src/lib/services/supplemental-forms.service.ts` mints
|
|
|
|
|
|
tokens for the CRM-hosted `/supplemental/[token]` route. → **Full
|
|
|
|
|
|
implementation needed.**
|
|
|
|
|
|
|
|
|
|
|
|
### A.2 — Supplemental form per-port: per-file change list
|
|
|
|
|
|
|
|
|
|
|
|
1. **`src/lib/settings/registry.ts`** — Add a new entry:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'supplemental_form_url',
|
|
|
|
|
|
section: 'email.general', // or new 'supplemental' section
|
|
|
|
|
|
label: 'Supplemental form URL (optional)',
|
|
|
|
|
|
description:
|
|
|
|
|
|
'When set, supplemental-info emails link to this URL with ?token=… appended. Leave blank to use the built-in CRM form at /supplemental/<token>.',
|
|
|
|
|
|
type: 'string',
|
|
|
|
|
|
scope: 'port',
|
|
|
|
|
|
placeholder: 'https://portnimara.com/supplemental',
|
|
|
|
|
|
},
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
2. **`src/lib/services/port-config.ts`** — Map the new key:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
supplementalFormUrl: 'supplemental_form_url',
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
3. **Email send-out call site** — Find via:
|
|
|
|
|
|
`grep -rn "supplemental" src/lib/email src/lib/services/sales-emails*`
|
|
|
|
|
|
The link assembly looks like:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
const cfg = await getPortEmailConfig(portId);
|
|
|
|
|
|
const url = cfg.supplementalFormUrl
|
|
|
|
|
|
? `${cfg.supplementalFormUrl}?token=${encodeURIComponent(raw)}`
|
|
|
|
|
|
: `${env.APP_URL}/supplemental/${raw}`;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
4. **Admin page** — Re-render via `<RegistryDrivenForm sections={['email.general']} />`
|
|
|
|
|
|
(or new section). No JSX edit needed if the section key matches an
|
|
|
|
|
|
existing card.
|
|
|
|
|
|
|
|
|
|
|
|
5. **Fallback route confirmation** — `src/app/(portal)/public/supplemental-info`
|
|
|
|
|
|
stays as-is. Adds copy "If you don't see your details, contact your rep."
|
|
|
|
|
|
|
|
|
|
|
|
### A.3 — Test plan additions
|
|
|
|
|
|
|
|
|
|
|
|
- **Vitest unit:** `supplemental-form-link.test.ts` —
|
|
|
|
|
|
`resolveSupplementalUrl(cfg, raw)` returns external URL when set,
|
|
|
|
|
|
CRM URL when blank.
|
|
|
|
|
|
- **Vitest integration:** `supplemental-email-send.test.ts` — mocks
|
|
|
|
|
|
a port with `supplemental_form_url` set; assert sent email body
|
|
|
|
|
|
contains the external URL.
|
|
|
|
|
|
- **Playwright (smoke):** admin can set + clear the URL; UI persists.
|
|
|
|
|
|
|
|
|
|
|
|
### A.4 — Phase 1 effort revision
|
|
|
|
|
|
|
|
|
|
|
|
Given 1.1 + 1.2 are already shipped, real remaining work is ~3–4h:
|
|
|
|
|
|
|
|
|
|
|
|
- 1.3 signing-invitation copy audit: ~1h
|
|
|
|
|
|
- 1.3 nginx CORS: 5min if it's already documented, ~30min if not
|
|
|
|
|
|
- 1.4 supplemental form: ~2h
|
|
|
|
|
|
- Tests + smoke: ~30min
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix B — Phase 2 (Deal-pulse signals + admin config UI)
|
|
|
|
|
|
|
|
|
|
|
|
### B.1 — Schema migration SQL
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 0072_pulse_admin_config.sql
|
|
|
|
|
|
-- All keys are stored in `system_settings` as JSON values with the
|
|
|
|
|
|
-- standard per-port scoping. No new columns or tables needed; the
|
|
|
|
|
|
-- registry-driven form handles serialization.
|
|
|
|
|
|
|
|
|
|
|
|
-- No DDL — registry entries below seed the keys lazily on first read.
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### B.2 — Registry entries to add
|
|
|
|
|
|
|
|
|
|
|
|
In `src/lib/settings/registry.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// ─── Deal Pulse ────────────────────────────────────────────────────
|
|
|
|
|
|
{ key: 'pulse_enabled', section: 'pulse', label: 'Show deal pulse chips',
|
|
|
|
|
|
description: 'Master toggle. Off hides every pulse chip on every surface.',
|
|
|
|
|
|
type: 'boolean', scope: 'port', defaultValue: 'true' },
|
|
|
|
|
|
{ key: 'pulse_signal_eoi_sent_enabled', section: 'pulse',
|
|
|
|
|
|
label: 'Signal: EOI sent', type: 'boolean', scope: 'port', defaultValue: 'true' },
|
|
|
|
|
|
{ key: 'pulse_signal_deposit_received_enabled', /* ... */ },
|
|
|
|
|
|
{ key: 'pulse_signal_contract_signed_enabled', /* ... */ },
|
|
|
|
|
|
{ key: 'pulse_signal_document_declined_enabled', /* ... */ },
|
|
|
|
|
|
{ key: 'pulse_signal_reservation_cancelled_enabled', /* ... */ },
|
|
|
|
|
|
{ key: 'pulse_signal_berth_sold_to_other_enabled', /* ... */ },
|
|
|
|
|
|
{ key: 'pulse_label_hot', section: 'pulse',
|
|
|
|
|
|
label: '"Hot" label override (default: Hot)',
|
|
|
|
|
|
description: 'Empty = use built-in label.',
|
|
|
|
|
|
type: 'string', scope: 'port' },
|
|
|
|
|
|
{ key: 'pulse_label_quiet', /* default: "Quiet" */ },
|
|
|
|
|
|
{ key: 'pulse_label_at_risk', /* default: "At Risk" */ },
|
|
|
|
|
|
{ key: 'pulse_label_critical', /* default: "Critical" */ },
|
|
|
|
|
|
{ key: 'pulse_label_eoi_sent', /* default: "EOI sent" */ },
|
|
|
|
|
|
{ key: 'pulse_label_deposit_received', /* default: "Deposit paid" */ },
|
|
|
|
|
|
{ key: 'pulse_label_contract_signed', /* default: "Contract signed" */ },
|
|
|
|
|
|
{ key: 'pulse_label_document_declined', /* default: "Declined" */ },
|
|
|
|
|
|
{ key: 'pulse_label_reservation_cancelled', /* default: "Reservation cancelled" */ },
|
|
|
|
|
|
{ key: 'pulse_label_berth_sold_to_other', /* default: "Berth resold" */ },
|
|
|
|
|
|
{ key: 'pulse_cadence_warning_days', section: 'pulse',
|
|
|
|
|
|
label: 'Warning threshold (days)', type: 'number', scope: 'port',
|
|
|
|
|
|
defaultValue: '7' },
|
|
|
|
|
|
{ key: 'pulse_cadence_critical_days', /* default 21 */ },
|
|
|
|
|
|
{ key: 'pulse_cadence_terminal_days', /* default 45 */ },
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### B.3 — Signal-firing hook sites
|
|
|
|
|
|
|
|
|
|
|
|
| Signal | Hook file | Hook function |
|
|
|
|
|
|
| ----------------------- | --------------------------------------------- | ------------------------------------------------------------ |
|
|
|
|
|
|
| `eoi_sent` | `src/lib/services/documents.service.ts` | `sendDocument` / `markAsSent` |
|
|
|
|
|
|
| `deposit_received` | `src/lib/services/invoices.service.ts` | `markPaid` (filter `purpose='deposit'`) |
|
|
|
|
|
|
| `contract_signed` | `src/lib/services/documents.service.ts` | `handleDocumentCompleted` (filter `templateType='contract'`) |
|
|
|
|
|
|
| `document_declined` | `src/lib/services/documents.service.ts` | `handleDocumentRejected` |
|
|
|
|
|
|
| `reservation_cancelled` | `src/lib/services/reservations.service.ts` | `cancelReservation` |
|
|
|
|
|
|
| `berth_sold_to_other` | `src/lib/services/interest-berths.service.ts` | `upsertInterestBerth` when conflict detected |
|
|
|
|
|
|
|
|
|
|
|
|
Each hook fires the signal by emitting a row into a new lightweight
|
|
|
|
|
|
table OR by recording a timestamp on the interest. Recommend the
|
|
|
|
|
|
timestamp pattern (no new table):
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
ALTER TABLE interests
|
|
|
|
|
|
ADD COLUMN pulse_last_eoi_sent_at timestamptz,
|
|
|
|
|
|
ADD COLUMN pulse_last_deposit_received_at timestamptz,
|
|
|
|
|
|
ADD COLUMN pulse_last_contract_signed_at timestamptz,
|
|
|
|
|
|
ADD COLUMN pulse_last_document_declined_at timestamptz,
|
|
|
|
|
|
ADD COLUMN pulse_last_reservation_cancelled_at timestamptz,
|
|
|
|
|
|
ADD COLUMN pulse_last_berth_sold_to_other_at timestamptz;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The pulse compute function then reads these columns + the per-port
|
|
|
|
|
|
admin config to assemble the chip output.
|
|
|
|
|
|
|
|
|
|
|
|
### B.4 — Pulse compute function refactor
|
|
|
|
|
|
|
|
|
|
|
|
`src/lib/services/deal-pulse.service.ts:computePulseFor(interestId)`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export interface PulseResult {
|
|
|
|
|
|
visible: boolean; // false if master toggle off
|
|
|
|
|
|
tier: 'neutral' | 'hot' | 'quiet' | 'at_risk' | 'critical';
|
|
|
|
|
|
tierLabel: string; // resolved from per-port label override or default
|
|
|
|
|
|
signals: Array<{
|
|
|
|
|
|
kind: 'eoi_sent' | 'deposit_received' | /* ... */;
|
|
|
|
|
|
label: string; // resolved
|
|
|
|
|
|
at: Date;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The function:
|
|
|
|
|
|
|
|
|
|
|
|
1. Reads `pulse_enabled` → returns `{ visible: false }` early if off.
|
|
|
|
|
|
2. Reads per-signal toggles + label overrides into a memoized config.
|
|
|
|
|
|
3. Reads cadence-tier thresholds.
|
|
|
|
|
|
4. Computes tier from `stage_entered_at` against thresholds.
|
|
|
|
|
|
5. Builds the signals array — most-recent first, filtered by toggle
|
|
|
|
|
|
state.
|
|
|
|
|
|
|
|
|
|
|
|
### B.5 — Admin page
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx`:
|
|
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
export default function PulseSettingsPage() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<PageHeader title="Deal Pulse" description="…" />
|
|
|
|
|
|
<RegistryDrivenForm
|
|
|
|
|
|
sections={['pulse']}
|
|
|
|
|
|
title="Pulse chip behaviour"
|
|
|
|
|
|
description="Toggle the chip, rename labels per port, tune cadence thresholds."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Add a link entry in `src/components/admin/admin-sections-browser.tsx`.
|
|
|
|
|
|
|
|
|
|
|
|
### B.6 — UI usage
|
|
|
|
|
|
|
|
|
|
|
|
`<DealPulseChip>` already exists. Extend it to:
|
|
|
|
|
|
|
|
|
|
|
|
1. Accept the full `PulseResult` (not just the tier).
|
|
|
|
|
|
2. Hide entirely when `visible: false`.
|
|
|
|
|
|
3. Render signal chips on hover/expand with their resolved labels.
|
|
|
|
|
|
|
|
|
|
|
|
### B.7 — Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- **Unit per signal firing:** Insert an interest, trigger the upstream
|
|
|
|
|
|
event, assert the `pulse_last_<signal>_at` column updated.
|
|
|
|
|
|
- **Unit per signal toggling:** With master toggle off → `computePulseFor`
|
|
|
|
|
|
returns `{ visible: false }`. With per-signal toggle off → signal
|
|
|
|
|
|
absent from `signals[]`.
|
|
|
|
|
|
- **Unit per cadence:** Interest with `stage_entered_at` at boundaries
|
|
|
|
|
|
(6d, 7d, 21d, 22d, 45d, 46d) — tier transitions match.
|
|
|
|
|
|
- **Integration:** Admin page round-trips config save + read; chip
|
|
|
|
|
|
reflects changes within the request lifetime cache window.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix C — Phase 3 (EOI field overrides) — comprehensive
|
|
|
|
|
|
|
|
|
|
|
|
### C.1 — Decision rationale (locked from user input)
|
|
|
|
|
|
|
|
|
|
|
|
1. Contact-channel dropdowns show every `client_contacts` row for that
|
|
|
|
|
|
channel, defaulting to the row with `is_primary=true`.
|
|
|
|
|
|
2. Override behaviours, controlled by two checkboxes below each field:
|
|
|
|
|
|
- Neither ticked → write to `documents.override_<field>` only.
|
|
|
|
|
|
- "Use only for this EOI" ticked → same as above (explicit).
|
|
|
|
|
|
- "Save as new contact" → insert `client_contacts` row,
|
|
|
|
|
|
`is_primary=false`, `source='eoi-custom-input'`.
|
|
|
|
|
|
- "Set as default for future docs" → above + promote new row to
|
|
|
|
|
|
`is_primary=true`, demote prior primary inside one transaction.
|
|
|
|
|
|
3. Badge label: `[EOI]` (not `[EOI Only]`).
|
|
|
|
|
|
4. Yacht overrides: spawn new yacht via inline Sheet + `<YachtForm>`.
|
|
|
|
|
|
New yacht tagged `yachts.source='eoi-generated'` and
|
|
|
|
|
|
`yachts.source_document_id=<doc-id>`. Original yacht untouched.
|
|
|
|
|
|
5. Audit trail: every action emits `audit_log` row with action
|
|
|
|
|
|
`eoi_field_override`, `promote_to_primary`, or `eoi_spawn_yacht`.
|
|
|
|
|
|
|
|
|
|
|
|
### C.2 — Schema migration SQL
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 0073_eoi_overrides.sql
|
|
|
|
|
|
|
|
|
|
|
|
-- Track origin of contacts so non-primary rows surface as "[EOI]"
|
|
|
|
|
|
-- and so we can reverse-link them to the generating document.
|
|
|
|
|
|
ALTER TABLE client_contacts
|
|
|
|
|
|
ADD COLUMN source text NOT NULL DEFAULT 'manual',
|
|
|
|
|
|
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
|
|
|
|
|
|
ADD CONSTRAINT chk_client_contacts_source
|
|
|
|
|
|
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
|
|
|
|
|
|
|
|
|
|
|
|
-- Same pattern for addresses.
|
|
|
|
|
|
ALTER TABLE client_addresses
|
|
|
|
|
|
ADD COLUMN source text NOT NULL DEFAULT 'manual',
|
|
|
|
|
|
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
|
|
|
|
|
|
ADD CONSTRAINT chk_client_addresses_source
|
|
|
|
|
|
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
|
|
|
|
|
|
|
|
|
|
|
|
-- Yacht origin tracking.
|
|
|
|
|
|
ALTER TABLE yachts
|
|
|
|
|
|
ADD COLUMN source text NOT NULL DEFAULT 'manual',
|
|
|
|
|
|
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
|
|
|
|
|
|
ADD CONSTRAINT chk_yachts_source
|
|
|
|
|
|
CHECK (source IN ('manual', 'imported', 'eoi-generated'));
|
|
|
|
|
|
|
|
|
|
|
|
-- Per-document overrides — stored on the document itself, separate
|
|
|
|
|
|
-- from the canonical client/yacht records. The full field set mirrors
|
|
|
|
|
|
-- VALID_MERGE_TOKENS from src/lib/templates/merge-fields.ts.
|
|
|
|
|
|
ALTER TABLE documents
|
|
|
|
|
|
ADD COLUMN override_client_email text,
|
|
|
|
|
|
ADD COLUMN override_client_phone text,
|
|
|
|
|
|
ADD COLUMN override_client_address_line_1 text,
|
|
|
|
|
|
ADD COLUMN override_client_address_line_2 text,
|
|
|
|
|
|
ADD COLUMN override_client_city text,
|
|
|
|
|
|
ADD COLUMN override_client_state text,
|
|
|
|
|
|
ADD COLUMN override_client_postal_code text,
|
|
|
|
|
|
ADD COLUMN override_client_country text,
|
|
|
|
|
|
ADD COLUMN override_yacht_name text,
|
|
|
|
|
|
ADD COLUMN override_yacht_length_ft numeric(10,2),
|
|
|
|
|
|
ADD COLUMN override_yacht_width_ft numeric(10,2),
|
|
|
|
|
|
ADD COLUMN override_yacht_draft_ft numeric(10,2);
|
|
|
|
|
|
|
|
|
|
|
|
-- Audit-actions enum gains 3 new verbs. Drizzle treats these as
|
|
|
|
|
|
-- string union — update the enum definition + run the seed audit.
|
|
|
|
|
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_field_override';
|
|
|
|
|
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'promote_to_primary';
|
|
|
|
|
|
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_spawn_yacht';
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### C.3 — Drizzle schema updates
|
|
|
|
|
|
|
|
|
|
|
|
`src/lib/db/schema/clients.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export const clientContacts = pgTable('client_contacts', {
|
|
|
|
|
|
// existing columns...
|
|
|
|
|
|
source: text('source').notNull().default('manual'),
|
|
|
|
|
|
sourceDocumentId: text('source_document_id').references(() => documents.id, {
|
|
|
|
|
|
onDelete: 'set null',
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Mirror in `client_addresses` and `yachts.ts`. Add the override columns
|
|
|
|
|
|
to `documents.ts`. Drop in `EoiOverrideValuesSchema` zod type at
|
|
|
|
|
|
`src/lib/validators/documents.ts`.
|
|
|
|
|
|
|
|
|
|
|
|
### C.4 — API endpoints
|
|
|
|
|
|
|
|
|
|
|
|
#### C.4.1 Promote contact to primary
|
|
|
|
|
|
|
|
|
|
|
|
`POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary`
|
|
|
|
|
|
|
|
|
|
|
|
Request: empty body.
|
|
|
|
|
|
|
|
|
|
|
|
Response: `{ data: { promoted: <ClientContact>, demoted: <ClientContact> | null } }`.
|
|
|
|
|
|
|
|
|
|
|
|
Implementation: wrap demote + promote in a transaction. Reject if the
|
|
|
|
|
|
target is already primary. Emits `audit_log` with action
|
|
|
|
|
|
`promote_to_primary`, metadata `{ channel, prior_primary_id }`.
|
|
|
|
|
|
|
|
|
|
|
|
#### C.4.2 Promote address to primary
|
|
|
|
|
|
|
|
|
|
|
|
`POST /api/v1/clients/[id]/addresses/[addressId]/promote-to-primary` —
|
|
|
|
|
|
mirror of C.4.1.
|
|
|
|
|
|
|
|
|
|
|
|
#### C.4.3 Generate EOI with overrides
|
|
|
|
|
|
|
|
|
|
|
|
Existing route `POST /api/v1/document-templates/[id]/generate-and-sign`
|
|
|
|
|
|
extends its request schema:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export const generateAndSignSchema = z.object({
|
|
|
|
|
|
interestId: z.string(),
|
|
|
|
|
|
pathway: z.enum(['documenso', 'in-app']),
|
|
|
|
|
|
overrides: z
|
|
|
|
|
|
.object({
|
|
|
|
|
|
values: z.record(z.string(), z.string()).optional(),
|
|
|
|
|
|
// For each overridden field, the rep can pick:
|
|
|
|
|
|
// - 'document-only': write to documents.override_<field>
|
|
|
|
|
|
// - 'save-secondary': insert client_contacts/addresses row, not promoted
|
|
|
|
|
|
// - 'save-primary': insert + promote (demotes prior primary)
|
|
|
|
|
|
persistence: z
|
|
|
|
|
|
.record(z.string(), z.enum(['document-only', 'save-secondary', 'save-primary']))
|
|
|
|
|
|
.optional(),
|
|
|
|
|
|
// Yacht-spawn signal: rep clicked "+ New yacht" inline.
|
|
|
|
|
|
// The yacht is created via POST /api/v1/yachts before this call
|
|
|
|
|
|
// and its id passed through here.
|
|
|
|
|
|
spawnedYachtId: z.string().optional(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.optional(),
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Service layer applies persistence per field inside one transaction so
|
|
|
|
|
|
a downstream error rolls back contact/address inserts.
|
|
|
|
|
|
|
|
|
|
|
|
#### C.4.4 Yacht create from EOI
|
|
|
|
|
|
|
|
|
|
|
|
`POST /api/v1/yachts` accepts new optional fields:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
{
|
|
|
|
|
|
// existing required: name, ownerType, ownerId, ...
|
|
|
|
|
|
source: 'eoi-generated' | 'manual',
|
|
|
|
|
|
sourceDocumentId?: string | null, // populated when source === 'eoi-generated'
|
|
|
|
|
|
interestId?: string, // when set, auto-link as interest's yacht
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### C.5 — UI surface — per file
|
|
|
|
|
|
|
|
|
|
|
|
#### C.5.1 `<EoiGenerateDialog>` (or rename to Sheet per CLAUDE.md)
|
|
|
|
|
|
|
|
|
|
|
|
File: `src/components/documents/eoi-generate-dialog.tsx` (existing).
|
|
|
|
|
|
|
|
|
|
|
|
Per field (email, phone, address, yacht):
|
|
|
|
|
|
|
|
|
|
|
|
1. Replace `<Input>` with `<Combobox>` populated from multi-value rows.
|
|
|
|
|
|
2. Below: 2 checkboxes:
|
|
|
|
|
|
- `[ ] Use only for this EOI`
|
|
|
|
|
|
- `[ ] Set as default for future docs`
|
|
|
|
|
|
3. For yacht field: append "+ New yacht" button next to the dropdown
|
|
|
|
|
|
that opens an inline Sheet (`<Sheet side="right">`) wrapping the
|
|
|
|
|
|
existing `<YachtForm>`. On save, new yacht is preselected.
|
|
|
|
|
|
|
|
|
|
|
|
#### C.5.2 Client detail panel — contacts list
|
|
|
|
|
|
|
|
|
|
|
|
File: `src/components/clients/client-form.tsx` (or wherever contacts list).
|
|
|
|
|
|
|
|
|
|
|
|
- Add `[EOI]` chip on rows where `source === 'eoi-custom-input'`.
|
|
|
|
|
|
- Add "Set as primary" inline action on non-primary rows; calls
|
|
|
|
|
|
C.4.1.
|
|
|
|
|
|
|
|
|
|
|
|
#### C.5.3 Yacht detail panel
|
|
|
|
|
|
|
|
|
|
|
|
File: `src/components/yachts/yacht-form.tsx` (or detail page).
|
|
|
|
|
|
|
|
|
|
|
|
- Show `[EOI]` chip when `yacht.source === 'eoi-generated'`.
|
|
|
|
|
|
- Link "Generated from EOI: <doc title>" pointing at
|
|
|
|
|
|
`/documents/<source_document_id>` when present.
|
|
|
|
|
|
|
|
|
|
|
|
### C.6 — Sub-session breakdown (5 sub-sessions)
|
|
|
|
|
|
|
|
|
|
|
|
- **3a — Schema + service + APIs (3 days):** Migration, Drizzle
|
|
|
|
|
|
schema, promote-to-primary endpoints, generate-and-sign extension,
|
|
|
|
|
|
yacht-create extension. Unit tests for each service function.
|
|
|
|
|
|
- **3b — EOI dialog UI (3 days):** Combobox + checkboxes + persistence
|
|
|
|
|
|
call. Vitest component snapshots.
|
|
|
|
|
|
- **3c — Yacht spawn (2 days):** Inline Sheet + YachtForm reuse +
|
|
|
|
|
|
preselect. E2E smoke for full flow.
|
|
|
|
|
|
- **3d — Client/yacht detail surfacing (1 day):** Badges, set-primary
|
|
|
|
|
|
actions, source-doc link.
|
|
|
|
|
|
- **3e — Audit + docs (1 day):** Audit-log entries surfacing in
|
|
|
|
|
|
`/admin/audit`, audit-action filter chips, README + CLAUDE.md
|
|
|
|
|
|
updates.
|
|
|
|
|
|
|
|
|
|
|
|
### C.7 — Open implementation questions (Phase 3 only)
|
|
|
|
|
|
|
|
|
|
|
|
- Should `documents.override_*` columns be archived to a JSONB blob
|
|
|
|
|
|
instead? Recommend NO — typed columns are query-friendly for the
|
|
|
|
|
|
promote-to-primary "where used" view in admin.
|
|
|
|
|
|
- Should the EOI dialog warn the rep when their pick is already the
|
|
|
|
|
|
primary? Recommend YES — small UX nicety, prevents accidental
|
|
|
|
|
|
duplicate rows.
|
|
|
|
|
|
- Yacht spawn from EOI: should the new yacht inherit the interest's
|
|
|
|
|
|
current yacht's berth links? Recommend NO — yachts are independent;
|
|
|
|
|
|
rep can copy manually.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix D — Phase 4 (Reminders) — comprehensive
|
|
|
|
|
|
|
|
|
|
|
|
### D.1 — Schema migration SQL
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 0074_reminders_expansion.sql
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE interests
|
|
|
|
|
|
ADD COLUMN reminder_note text;
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE user_profiles
|
|
|
|
|
|
ADD COLUMN digest_time_of_day time NOT NULL DEFAULT '09:00';
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE reminders
|
|
|
|
|
|
ADD COLUMN fired_at timestamptz;
|
|
|
|
|
|
|
|
|
|
|
|
-- Worker idempotency: ensure two parallel workers can't double-fire.
|
|
|
|
|
|
CREATE UNIQUE INDEX uniq_reminders_fired_once
|
|
|
|
|
|
ON reminders (id)
|
|
|
|
|
|
WHERE fired_at IS NOT NULL;
|
|
|
|
|
|
-- (logically unique by PK anyway, but the index serves as a self-
|
|
|
|
|
|
-- documenting fingerprint for the worker's "did I already fire?" check.)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### D.2 — Service additions
|
|
|
|
|
|
|
|
|
|
|
|
`src/lib/services/reminders.service.ts` (existing — extend):
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export async function createReminder(input: {
|
|
|
|
|
|
portId: string;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
assigneeId?: string; // defaults to userId
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
note?: string;
|
|
|
|
|
|
priority?: 'low' | 'medium' | 'high';
|
|
|
|
|
|
dueAt: Date;
|
|
|
|
|
|
linkedEntityType?: 'interest' | 'client' | 'berth' | 'yacht' | null;
|
|
|
|
|
|
linkedEntityId?: string | null;
|
|
|
|
|
|
}): Promise<Reminder> {
|
|
|
|
|
|
/* ... */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listReminderInbox(input: {
|
|
|
|
|
|
portId: string;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
filter: 'mine' | 'all_port';
|
|
|
|
|
|
}): Promise<Reminder[]> {
|
|
|
|
|
|
/* ... */
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### D.3 — Worker scheduler refactor
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/jobs/processors/reminder-firing.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// Runs every 15 minutes via the BullMQ scheduler.
|
|
|
|
|
|
export async function fireReadyReminders(): Promise<void> {
|
|
|
|
|
|
// Per-port advisory lock to prevent two workers double-firing.
|
|
|
|
|
|
for (const port of await listPortIds()) {
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(${hashPortToBigint(port)})`);
|
|
|
|
|
|
const due = await tx
|
|
|
|
|
|
.select()
|
|
|
|
|
|
.from(reminders)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(reminders.portId, port),
|
|
|
|
|
|
lte(reminders.dueAt, new Date()),
|
|
|
|
|
|
isNull(reminders.firedAt),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const r of due) {
|
|
|
|
|
|
await fireOne(r, tx);
|
|
|
|
|
|
await tx.update(reminders).set({ firedAt: new Date() }).where(eq(reminders.id, r.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### D.4 — UI: shared dialog component
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/components/reminders/create-reminder-dialog.tsx`:
|
|
|
|
|
|
|
|
|
|
|
|
Fields: Title (required), Note (optional, textarea), Due date+time
|
|
|
|
|
|
(defaults to today + user's `digest_time_of_day`), Priority dropdown,
|
|
|
|
|
|
Assignee combobox (port users via `/api/v1/admin/users/picker`),
|
|
|
|
|
|
Linked entity dropdown (hidden when pre-filled).
|
|
|
|
|
|
|
|
|
|
|
|
### D.5 — Mount points
|
|
|
|
|
|
|
|
|
|
|
|
1. `src/components/reminders/reminders-inbox.tsx`: `[+ New task]`
|
|
|
|
|
|
button in toolbar.
|
|
|
|
|
|
2. `src/components/interests/interest-detail-header.tsx`: `[+ Task]`
|
|
|
|
|
|
button next to existing Reminders panel.
|
|
|
|
|
|
3. Mirror for clients/berths/yachts detail pages.
|
|
|
|
|
|
|
|
|
|
|
|
### D.6 — Settings page
|
|
|
|
|
|
|
|
|
|
|
|
`src/app/(dashboard)/[portSlug]/settings/notifications/page.tsx`
|
|
|
|
|
|
(or wherever user-level prefs live): add a time picker bound to
|
|
|
|
|
|
`user_profiles.digest_time_of_day` via PATCH `/api/v1/me/profile`.
|
|
|
|
|
|
|
|
|
|
|
|
### D.7 — Sub-session breakdown
|
|
|
|
|
|
|
|
|
|
|
|
- **4a — Schema + service + worker (1.5 days)**
|
|
|
|
|
|
- **4b — Dialog component + 4 mount points (1.5 days)**
|
|
|
|
|
|
- **4c — Settings page time-of-day picker + tests (0.5 days)**
|
|
|
|
|
|
- **4d — Integration + E2E (0.5 days)**
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix E — Phase 5 (Email-copy refactor) — comprehensive
|
|
|
|
|
|
|
|
|
|
|
|
### E.1 — Old-CRM reference location (captured)
|
|
|
|
|
|
|
|
|
|
|
|
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
|
|
|
|
|
|
|
|
|
|
|
|
Notable files:
|
|
|
|
|
|
|
|
|
|
|
|
- `server/utils/email.ts` — Nodemailer wrapper with subject/html shape.
|
|
|
|
|
|
- `server/tasks/process-sales-emails.ts` — automated send-out cadence.
|
|
|
|
|
|
- `components/EmailComposer.vue` — UI tone reference.
|
|
|
|
|
|
- `components/EmailCommunication.vue` — body markdown handling.
|
|
|
|
|
|
|
|
|
|
|
|
**Step 1 of execution:** open these three files, capture 3–5 representative
|
|
|
|
|
|
template strings, quote in PR description for reviewer traceability.
|
|
|
|
|
|
|
|
|
|
|
|
### E.2 — Templates to refactor (per-file)
|
|
|
|
|
|
|
|
|
|
|
|
Current `src/lib/email/templates/`:
|
|
|
|
|
|
|
|
|
|
|
|
- `portal-auth.ts` — activation + reset (already branded, voice pass needed)
|
|
|
|
|
|
- `signing-invitation.ts` — voice-pass + role-specific copy completeness check
|
|
|
|
|
|
- `signing-completion.ts` — voice-pass
|
|
|
|
|
|
- `supplemental-info-request.ts` — voice-pass + link to per-port URL
|
|
|
|
|
|
(depends on Phase 1.4)
|
|
|
|
|
|
- `reminder-digest.ts` — voice-pass; ties into Phase 4 (reminders)
|
|
|
|
|
|
- `bounce-warning.ts` — voice-pass; depends on Phase 6 (bounce linking)
|
|
|
|
|
|
- `port-invitation.ts` (CRM invite) — voice-pass
|
|
|
|
|
|
- `change-email-confirmation.ts` — voice-pass
|
|
|
|
|
|
|
|
|
|
|
|
### E.3 — Branding chain audit
|
|
|
|
|
|
|
|
|
|
|
|
Grep `s3.portnimara.com` across `src/lib/email/templates/` — replace
|
|
|
|
|
|
hard-coded URLs with `cfg.portLogoUrl` / `cfg.portEmailFooter`.
|
|
|
|
|
|
|
|
|
|
|
|
Confirm every `sendEmail` callsite threads `portId` through to
|
|
|
|
|
|
`getPortEmailConfig(portId)` (not the env-fallback shape).
|
|
|
|
|
|
|
|
|
|
|
|
### E.4 — Tone guidance
|
|
|
|
|
|
|
|
|
|
|
|
After reading the old-CRM templates, write a 1-page tone guide at
|
|
|
|
|
|
`docs/email-tone-guide.md` capturing:
|
|
|
|
|
|
|
|
|
|
|
|
- Sentence cadence (concise, second-person, no marketing fluff).
|
|
|
|
|
|
- Salutation conventions ("Dear <Name>" vs "Hello <First>").
|
|
|
|
|
|
- Sign-off conventions (rep name + role + port name).
|
|
|
|
|
|
- Action-phrase tone ("you may sign here" vs "click to sign").
|
|
|
|
|
|
|
|
|
|
|
|
Reviewer uses the guide to verify each refactored template.
|
|
|
|
|
|
|
|
|
|
|
|
### E.5 — Test plan
|
|
|
|
|
|
|
|
|
|
|
|
- **Snapshot per template:** `pnpm exec vitest run src/lib/email/templates/**.test.ts`
|
|
|
|
|
|
asserts each template renders for port-nimara and a 2nd test port
|
|
|
|
|
|
with different logo + footer.
|
|
|
|
|
|
- **Manual test send:** seed 8 representative scenarios; send each
|
|
|
|
|
|
to a test inbox (real or `EMAIL_REDIRECT_TO`); manually verify the
|
|
|
|
|
|
output reads in tone.
|
|
|
|
|
|
|
|
|
|
|
|
### E.6 — Sub-session breakdown
|
|
|
|
|
|
|
|
|
|
|
|
- **5a — Reference capture (0.5 days):** Open old-CRM, capture tone
|
|
|
|
|
|
guide, write `docs/email-tone-guide.md`.
|
|
|
|
|
|
- **5b — Branding chain audit (0.5 days):** Grep hard-coded URLs;
|
|
|
|
|
|
fix every call to thread port-specific values.
|
|
|
|
|
|
- **5c — Tone pass batch 1 (1.5 days):** portal-auth, signing-\*,
|
|
|
|
|
|
port-invitation, change-email-confirmation.
|
|
|
|
|
|
- **5d — Tone pass batch 2 (1.5 days):** supplemental-info,
|
|
|
|
|
|
reminder-digest, bounce-warning (waits on Phase 4 + 6 if needed).
|
|
|
|
|
|
- **5e — Snapshot tests + manual sends (1 day).**
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix F — Phase 6 (IMAP bounce-to-interest linking) — comprehensive
|
|
|
|
|
|
|
|
|
|
|
|
### F.1 — Schema migration SQL
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 0075_bounce_tracking.sql
|
|
|
|
|
|
ALTER TABLE document_sends
|
|
|
|
|
|
ADD COLUMN bounce_status text,
|
|
|
|
|
|
ADD COLUMN bounce_reason text,
|
|
|
|
|
|
ADD COLUMN bounce_detected_at timestamptz,
|
|
|
|
|
|
ADD CONSTRAINT chk_document_sends_bounce_status
|
|
|
|
|
|
CHECK (bounce_status IS NULL OR bounce_status IN ('hard', 'soft', 'ooo'));
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_document_sends_bounce_status
|
|
|
|
|
|
ON document_sends (port_id, bounce_status)
|
|
|
|
|
|
WHERE bounce_status IS NOT NULL;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### F.2 — Parser fixtures (the long tail)
|
|
|
|
|
|
|
|
|
|
|
|
`tests/fixtures/bounces/`:
|
|
|
|
|
|
|
|
|
|
|
|
- `gmail-hard.eml` — Gmail user not found.
|
|
|
|
|
|
- `gmail-quota.eml` — Mailbox full (soft bounce).
|
|
|
|
|
|
- `outlook-hard.eml` — Recipient does not exist.
|
|
|
|
|
|
- `outlook-ooo.eml` — Out-of-office auto-reply.
|
|
|
|
|
|
- `postfix-permanent.eml` — Postfix 550.
|
|
|
|
|
|
- `postfix-temporary.eml` — Postfix 451.
|
|
|
|
|
|
- `exchange-quarantine.eml` — Quarantined.
|
|
|
|
|
|
- `gmail-blocked.eml` — Anti-spam block (hard).
|
|
|
|
|
|
|
|
|
|
|
|
Parser must extract: original-recipient address, bounce class
|
|
|
|
|
|
(hard/soft/ooo), reason string, in-reply-to header.
|
|
|
|
|
|
|
|
|
|
|
|
### F.3 — Parser API
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/lib/email/bounce-parser.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export interface ParsedBounce {
|
|
|
|
|
|
originalRecipient: string | null;
|
|
|
|
|
|
bounceClass: 'hard' | 'soft' | 'ooo' | 'unknown';
|
|
|
|
|
|
reason: string;
|
|
|
|
|
|
inReplyTo: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function parseBounce(raw: string | Buffer): ParsedBounce {
|
|
|
|
|
|
/* ... */
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Implementation uses `mailparser` (already in deps) for MIME parsing,
|
|
|
|
|
|
then a switch on `Content-Type` (multipart/report) vs subject-heuristics.
|
|
|
|
|
|
|
|
|
|
|
|
### F.4 — Cron worker
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/jobs/processors/imap-bounce-poller.ts`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export async function pollBounces(): Promise<void> {
|
|
|
|
|
|
for (const port of await listPortsWithImap()) {
|
|
|
|
|
|
const cfg = await getPortImapConfig(port.id);
|
|
|
|
|
|
const client = imapflow(cfg);
|
|
|
|
|
|
await client.connect();
|
|
|
|
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const messages = client.fetch({ since: oneHourAgo() }, { source: true });
|
|
|
|
|
|
for await (const msg of messages) {
|
|
|
|
|
|
const parsed = parseBounce(msg.source);
|
|
|
|
|
|
if (parsed.originalRecipient) {
|
|
|
|
|
|
await matchAndUpdateDocumentSend(port.id, parsed);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
lock.release();
|
|
|
|
|
|
await client.logout();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### F.5 — Matching algorithm
|
|
|
|
|
|
|
|
|
|
|
|
`matchAndUpdateDocumentSend(portId, parsed)`:
|
|
|
|
|
|
|
|
|
|
|
|
1. Find `document_sends` row where
|
|
|
|
|
|
`recipient_email = parsed.originalRecipient AND sent_at > now() - interval '7 days' AND bounce_status IS NULL`.
|
|
|
|
|
|
2. If found: update `bounce_status` + `bounce_reason` +
|
|
|
|
|
|
`bounce_detected_at`, fire notification to the sender (user_id from
|
|
|
|
|
|
document_sends.sent_by_user_id).
|
|
|
|
|
|
3. If not found: log + audit (the bounce may be for a stale send or a
|
|
|
|
|
|
non-CRM email).
|
|
|
|
|
|
|
|
|
|
|
|
### F.6 — UI surface
|
|
|
|
|
|
|
|
|
|
|
|
`src/components/interests/interest-emails-tab.tsx` (or wherever sends
|
|
|
|
|
|
render): red banner on rows where `bounce_status IS NOT NULL`. Banner
|
|
|
|
|
|
text: "Email bounced — <reason>".
|
|
|
|
|
|
|
|
|
|
|
|
Notification bell: new type `email_bounced` routed via existing
|
|
|
|
|
|
`createNotification` flow.
|
|
|
|
|
|
|
|
|
|
|
|
### F.7 — Sub-session breakdown
|
|
|
|
|
|
|
|
|
|
|
|
- **6a — Schema + parser + fixtures (2 days)**
|
|
|
|
|
|
- **6b — Cron worker + matching algorithm (1 day)**
|
|
|
|
|
|
- **6c — UI banner + notification + E2E (1 day)**
|
|
|
|
|
|
- **6d — Manual bounce round-trip test (0.5 days)**
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix G — Phase 7 (PDF template editor) — comprehensive
|
|
|
|
|
|
|
|
|
|
|
|
### G.1 — Library choices
|
|
|
|
|
|
|
|
|
|
|
|
- **PDF rendering:** `react-pdf` (already in deps). Limit to v7+ to
|
|
|
|
|
|
pick up the Canvas-free rendering path.
|
|
|
|
|
|
- **Coordinate system:** PDF native uses bottom-left origin; viewer
|
|
|
|
|
|
uses top-left. Wrap a single `coordTransformer` utility — never
|
|
|
|
|
|
scatter conversions.
|
|
|
|
|
|
- **Drag handles:** `react-draggable` (small footprint) for marker
|
|
|
|
|
|
movement. Resize via `react-resizable`. Both have stable types.
|
|
|
|
|
|
|
|
|
|
|
|
### G.2 — Schema migration
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 0076_pdf_template_field_map.sql
|
|
|
|
|
|
|
|
|
|
|
|
ALTER TABLE document_templates
|
|
|
|
|
|
ADD COLUMN field_map jsonb;
|
|
|
|
|
|
|
|
|
|
|
|
COMMENT ON COLUMN document_templates.field_map IS
|
|
|
|
|
|
'Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>
|
|
|
|
|
|
Coords are percent of page width/height (0..1) so they survive page-size changes.';
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### G.3 — Editor page
|
|
|
|
|
|
|
|
|
|
|
|
New file `src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx`:
|
|
|
|
|
|
|
|
|
|
|
|
Layout (desktop):
|
|
|
|
|
|
|
|
|
|
|
|
- Left: page picker (vertical thumbnails).
|
|
|
|
|
|
- Centre: PDF page render with overlay canvas for markers + drag handles.
|
|
|
|
|
|
- Right: field-map sidebar listing every marker with edit/delete actions.
|
|
|
|
|
|
- Bottom: "Add field" mode toggle + token autocomplete combobox.
|
|
|
|
|
|
|
|
|
|
|
|
### G.4 — Field-map API
|
|
|
|
|
|
|
|
|
|
|
|
`PUT /api/v1/document-templates/[id]/field-map`:
|
|
|
|
|
|
|
|
|
|
|
|
Request:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
{
|
|
|
|
|
|
fieldMap: Array<{
|
|
|
|
|
|
token: string; // must be in VALID_MERGE_TOKENS
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
x: number; // 0..1
|
|
|
|
|
|
y: number; // 0..1
|
|
|
|
|
|
w: number; // 0..1
|
|
|
|
|
|
h: number; // 0..1
|
|
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Response: `{ data: { id, fieldMap, updatedAt } }`.
|
|
|
|
|
|
|
|
|
|
|
|
Validation:
|
|
|
|
|
|
|
|
|
|
|
|
- Each token must exist in `VALID_MERGE_TOKENS` (rejects typos at the
|
|
|
|
|
|
API boundary — same allow-list pattern as `createTemplateSchema`).
|
|
|
|
|
|
- `0 <= x < 1`, `0 <= y < 1`, `0 < w <= 1 - x`, `0 < h <= 1 - y`.
|
|
|
|
|
|
- `page >= 1`.
|
|
|
|
|
|
- Page count assertion: fetch the source PDF, count pages, reject if
|
|
|
|
|
|
any marker references a page beyond the count.
|
|
|
|
|
|
|
|
|
|
|
|
### G.5 — Preview API
|
|
|
|
|
|
|
|
|
|
|
|
`POST /api/v1/document-templates/[id]/preview`:
|
|
|
|
|
|
|
|
|
|
|
|
Request: `{ interestId: string }`.
|
|
|
|
|
|
|
|
|
|
|
|
Response: `{ data: { previewUrl: string } }` — signed URL (24h TTL) to
|
|
|
|
|
|
a transient PDF filled with the merge-field values pulled from the
|
|
|
|
|
|
specified interest's EoiContext.
|
|
|
|
|
|
|
|
|
|
|
|
Implementation reuses `fillEoiForm` from
|
|
|
|
|
|
`src/lib/pdf/fill-eoi-form.ts` with a per-call coord-list override
|
|
|
|
|
|
from the in-memory edit state.
|
|
|
|
|
|
|
|
|
|
|
|
### G.6 — Live preview wiring
|
|
|
|
|
|
|
|
|
|
|
|
The editor's right pane:
|
|
|
|
|
|
|
|
|
|
|
|
- Debounces edits at 500ms.
|
|
|
|
|
|
- POSTs to the preview endpoint.
|
|
|
|
|
|
- Renders the returned PDF inline via `react-pdf`.
|
|
|
|
|
|
|
|
|
|
|
|
### G.7 — Multi-page navigation
|
|
|
|
|
|
|
|
|
|
|
|
Page picker on the left scrolls the centre to the matching page +
|
|
|
|
|
|
keeps the field-map sidebar filtered to that page's markers.
|
|
|
|
|
|
|
|
|
|
|
|
Edge case: a marker on page 3 of a 5-page template stays visible in
|
|
|
|
|
|
the sidebar but greys out when page 1 is shown — clicking it jumps to
|
|
|
|
|
|
page 3.
|
|
|
|
|
|
|
|
|
|
|
|
### G.8 — New-PDF upload
|
|
|
|
|
|
|
|
|
|
|
|
When admin uploads a replacement PDF:
|
|
|
|
|
|
|
|
|
|
|
|
1. Compute MD5 of old + new PDFs — block upload if identical.
|
|
|
|
|
|
2. Compare page counts. If different, surface a warning modal with
|
|
|
|
|
|
the diff ("Existing template has 5 pages, new has 3. 2 fields on
|
|
|
|
|
|
pages 4+ will be removed.").
|
|
|
|
|
|
3. On confirm: replace source via the existing template-upload flow,
|
|
|
|
|
|
pruning out-of-range fields from `field_map`.
|
|
|
|
|
|
|
|
|
|
|
|
### G.9 — Performance budget
|
|
|
|
|
|
|
|
|
|
|
|
- Largest production template: ~12 pages, ~600KB.
|
|
|
|
|
|
- Editor LCP target: <2s on a 2017 MBP (worst common-case sales rep).
|
|
|
|
|
|
- `react-pdf` worker mode (loadPdfWithWorker) keeps the main thread
|
|
|
|
|
|
responsive during page rendering.
|
|
|
|
|
|
- Field-map state lives in a single `useReducer`; debounced
|
|
|
|
|
|
serialization avoids per-keystroke API hits.
|
|
|
|
|
|
|
|
|
|
|
|
### G.10 — Sub-session breakdown
|
|
|
|
|
|
|
|
|
|
|
|
- **7.1a — PDF render + page picker + read-only viewer (4 days):** No
|
|
|
|
|
|
field placement yet — just confirm `react-pdf` performs well on
|
|
|
|
|
|
production templates and the editor shell renders.
|
|
|
|
|
|
- **7.1b — Field placement (drop marker, save field-map, list) (5 days)**
|
|
|
|
|
|
- **7.1c — Field-map API + validation + tests (3 days)**
|
|
|
|
|
|
- **7.2a — Drag-move + resize markers (3 days)**
|
|
|
|
|
|
- **7.2b — Preview pane + signed-URL serving (4 days)**
|
|
|
|
|
|
- **7.2c — New-PDF upload + diff warning (3 days)**
|
|
|
|
|
|
- **7.2d — Multi-page navigation + edge cases (2 days)**
|
|
|
|
|
|
|
|
|
|
|
|
### G.11 — Open implementation questions
|
|
|
|
|
|
|
|
|
|
|
|
- Should the editor support conditional field placement (e.g.,
|
|
|
|
|
|
"yacht_name" only renders when yacht is set)? Defer to Phase 3.
|
|
|
|
|
|
- Should the editor surface AcroForm fields embedded in the source PDF
|
|
|
|
|
|
separately from CRM-managed markers? Recommend YES — the existing
|
|
|
|
|
|
`assets/eoi-template.pdf` AcroForm flow should keep working alongside
|
|
|
|
|
|
the new percent-coord marker flow. Need a UI toggle to switch view
|
|
|
|
|
|
modes.
|
|
|
|
|
|
- Multi-tenant: should each port have its own template editor URL, or
|
|
|
|
|
|
is templating port-scoped via system_settings? Templates are
|
|
|
|
|
|
port-scoped today, so the editor URL becomes
|
|
|
|
|
|
`/admin/templates/[id]/editor` with port resolution via the existing
|
|
|
|
|
|
port-context middleware.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Cross-phase risks + considerations
|
|
|
|
|
|
|
|
|
|
|
|
1. **Schema migrations are FK-heavy across Phases 3, 4, 6, 7.** Run
|
|
|
|
|
|
`pnpm db:generate` after each, inspect the generated SQL by eye,
|
|
|
|
|
|
apply to dev DB, restart `next dev` (per CLAUDE.md pool-cache note).
|
|
|
|
|
|
|
|
|
|
|
|
2. **Audit-action enum extensions need careful ordering.** Postgres
|
|
|
|
|
|
doesn't allow enum value re-ordering, so the audit display order
|
|
|
|
|
|
relies on a label map (`src/lib/audit-action-labels.ts`). Update
|
|
|
|
|
|
alongside each enum extension.
|
|
|
|
|
|
|
|
|
|
|
|
3. **Per-port admin pages multiply.** After all phases ship, this
|
|
|
|
|
|
adds: `/admin/pulse`, `/admin/templates/[id]/editor`. Confirm the
|
|
|
|
|
|
`<AdminSectionsBrowser>` index covers them.
|
|
|
|
|
|
|
|
|
|
|
|
4. **Worker process additions.** Phase 4 (reminders) and Phase 6
|
|
|
|
|
|
(bounces) both add cron-style jobs. Confirm `Dockerfile.worker`
|
|
|
|
|
|
wakes them up; capture metrics for monitoring.
|
|
|
|
|
|
|
|
|
|
|
|
5. **CLAUDE.md updates.** Each phase that adds doctrine (e.g. EOI
|
|
|
|
|
|
override marker badge, deal-pulse signal types) should land a
|
|
|
|
|
|
matching CLAUDE.md addition in the same PR so the AI assistant
|
|
|
|
|
|
doesn't unlearn the new patterns.
|
|
|
|
|
|
|
|
|
|
|
|
6. **PR sizing.** Each sub-session targets one or two coherent
|
|
|
|
|
|
commits. Avoid mega-PRs — the merge-conflict surface area on
|
|
|
|
|
|
Phase 7 in particular needs small, incremental PRs.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Appendix H — Already-shipped audit residuals (reference)
|
|
|
|
|
|
|
|
|
|
|
|
For traceability, the items completed before this plan started:
|
|
|
|
|
|
|
|
|
|
|
|
- Audit fix waves: 3/3 CRITICAL, 14/15 HIGH (1 N/A), 28+ MEDIUM, 6/8
|
|
|
|
|
|
LOW (commits `4b5f85c`, `0f99f05`).
|
|
|
|
|
|
- Documenso v2 polish: envelope-ID sync, signing-progress redesign,
|
|
|
|
|
|
20+ UX fixes.
|
|
|
|
|
|
- env→admin migration: 30+ registry vars, per-port encryption,
|
|
|
|
|
|
5 admin pages converted.
|
|
|
|
|
|
|
|
|
|
|
|
Master plan picks up after these.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Definition of done (cross-phase)
|
|
|
|
|
|
|
|
|
|
|
|
A phase is considered shipped when:
|
|
|
|
|
|
|
|
|
|
|
|
- All sub-sessions are ticked in the §"Phase ☑/☐ tracker" above.
|
|
|
|
|
|
- `pnpm exec vitest run` passes.
|
|
|
|
|
|
- `pnpm tsc --noEmit` passes.
|
|
|
|
|
|
- `pnpm lint` passes.
|
|
|
|
|
|
- For phases touching middleware/env/build config: `pnpm build` passes.
|
|
|
|
|
|
- For UI-facing phases: at least one smoke E2E spec is added (or an
|
|
|
|
|
|
existing spec extended) under `tests/e2e/smoke/`.
|
|
|
|
|
|
- CLAUDE.md updated with any new doctrine.
|
|
|
|
|
|
- This master plan is updated — phase marked ☑ with a one-line
|
|
|
|
|
|
outcome note inline.
|
|
|
|
|
|
- ☐ 7.2 Edit + preview
|