# 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//` 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/`, `/sign/contract/`, `/sign/reservation/` 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__enabled`. 3. **Label rename map** — `pulse_label_` 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__enabled boolean default true` for each signal - `pulse_label_ 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 - `` 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 `` — 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 - `` — each editable field becomes a `` 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 (``, per CLAUDE.md doctrine) with the existing ``. 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 `` — 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). - ``: `[+ 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 - `` row gets a red border + "Bounced: " 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). --- ## Session log ### Session 2026-05-18 PM — Phases 4 / 2 wiring / 6 / CLAUDE.md Three of the four "suggested execution order" items shipped; Phase 3b was deferred (effort estimate exceeded remaining session time). Recent commits leading into this session: ``` a6e7923 docs(plan): mark Phase 1+2 ☑, Phase 3-7 ◐ partial df1594d feat(email): Phase 5 — branding chain ext'd with per-port background 9f57868 feat(post-audit): Phase 3/6/7 schema foundations + bounce parser fb4a09e feat(reminders): Phase 4 partial — schema + service + validators 918c23f feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin ee3cbb9 docs(plan): expand master plan with detailed implementation appendix c9debce docs(plan): comprehensive 7-phase master plan for post-audit work 0f99f05 feat(post-audit): batch A+B quick-wins + audit-side residuals 4b5f85c fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish 397dbd1 docs(spec): env-to-admin migration design ``` Shipped this session: - ☑ CLAUDE.md trimmed 27KB → ~19.5KB; added Tools/Skills/MCPs section. - ☑ Phase 4 polish — yachtId field on `` + Ship subtitle on `` + `listReminders` filter + `getReminder` yacht relation join. - ☑ Phase 2 risk-signal data wiring — derivation pass in `getInterestById` (3 parallel queries) populates the 3 risk-signal dates from `document_events` / `berth_reservations` / cross-interest `interest_berths`. Chosen over new schema columns; documented in CLAUDE.md. - ☑ Phase 6 cron + UI — `imap-bounce-poller.ts` worker wired into maintenance queue at `*/15 * * * *`; matches NDRs to recent `document_sends` rows, fires `email_bounced` notification on hard/soft; admin `/admin/sends` page now shows bounce badge + reason banner. - Quality gates: 1374/1374 vitest pass, `tsc --noEmit` clean, `pnpm lint` zero errors (37 pre-existing warnings). Deferred: - Phase 3b — EOI dialog override UI (combobox per field + 2 checkboxes) was the 4th item; master-plan estimate is 2-3 days and exceeded remaining session time. - Phase 4 worker scheduler refactor (fired_at gate cron tick). - Phase 6 interest-detail "Emails" tab — the tab surface doesn't exist yet; bounce banner will live there when the tab lands. --- ## Phase ☑/☐ tracker - ☑ Phase 1 — Documenso completion + Supplemental form (commits df1594d, 918c23f) - ☑ 1.1 Documenso Phase 7 (RBAC) — already in code prior; verified at documents.service.ts:1268-1300 - ☑ 1.2 Documenso Phase 2 (Webhook UX cascading invite) — already in code prior; verified - ☑ 1.3 Documenso Phase 5 (Embedded signing) — copy made order-agnostic + developer-role branch - ☑ 1.4 Supplemental form per-port URL — registry + getPortEmailConfig + route - ☑ Phase 2 — Deal-pulse signals + admin config UI (918c23f, plus session 2026-05-18 PM) - Compute extended with 3 positive + 3 risk signals; admin page mounted at /admin/pulse - ☑ Data-wiring: derivation pass inside `getInterestById` — runs 3 parallel queries against `document_events` (rejected/declined), `berth_reservations` (status='cancelled'), and other `won` interests sharing a berth via `interest_berths`. Returns the 3 dates on the API response; `interest-detail-header` threads them through to ``. Chosen over new schema columns to keep the master plan's "no new tables" promise. Documented in CLAUDE.md. - ◐ Phase 3 — EOI field overrides (9f57868 + session 2026-05-18 PM) - ☑ 3a — Schema migration 0073, Drizzle additions, audit_actions free-text verbs - ☑ 3b — EOI dialog UI overrides for email/phone/yacht-name; service-level side-effects (create non-primary contact, promote-to-primary, write documents.override\_\*) inside a single transaction via `src/lib/services/eoi-overrides.service.ts`. Both pathways (inapp + Documenso template) layer overrides onto the in-memory EoiContext before render. Audit verbs `eoi_field_override` + `promote_to_primary` - `eoi_spawn_yacht` formalised in `src/lib/audit.ts`. Address overrides + per-yacht detail badge deferred. - ☑ 3c — "+ New yacht" button next to yacht-name field opens nested `` Sheet (pre-fills owner = current client, stamps `source='eoi-generated'`); on save, the interest's yachtId is patched so the EOI's yacht block populates without a manual re-link. - ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary` (transactional demote+promote via `promoteContactToPrimary`); `[EOI]` badge on non-primary contact rows in `` + on yacht detail header when `yacht.source === 'eoi-generated'`. - ☐ Address override field in EOI dialog (schema columns exist) - ☐ Audit-log UI surfacing of new verbs (rows written, filter chips missing) - ☐ Backfill yachts.source_document_id after EOI document is created (currently set NULL because the yacht is spawned BEFORE the doc row exists) - ◐ Phase 4 — Reminders (fb4a09e + session 2026-05-18 PM) - ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note - ☑ Service + validators accept yachtId with port-scoping check - ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope) - ☑ `` shows yacht subtitle (Ship icon + yacht name) - ☑ `listReminders` filters by query.yachtId; `getReminder` joins yacht relation - ☑ Worker `processOverdueReminders` claims due rows via `UPDATE...RETURNING` with `fired_at IS NULL` race-safe gate, so parallel workers can't double-fire the same reminder. - ☑ `user_profiles.preferences.digestTimeOfDay` picker on `/settings` (time input + help text). `` honours the preference via a React-Query me-prefs fetch keyed `['me', 'preferences']`. - ☑ Per-entity `[+ Reminder]` buttons on yacht / client / interest detail headers threading defaultYachtId / defaultClientId / defaultInterestId - ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD) - ☑ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM x3) - ☑ Per-port background URL — closes the last hard-coded portnimara.com asset - ☑ All 8 templates rewritten with luxury-port voice: portal-auth (activation + reset), inquiry-client-confirmation, notification-digest, document-signing, admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry. Voice: "Dear X", "With warm regards, The {portName} Team", subjects in sentence case ("Thank you for…" not "Thank You For…"). - ☐ Snapshot tests per template at port-nimara + 2nd test port (defer — would need a 2nd-port fixture set up; templates work in code review) - ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM) - ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends - ☑ Parser library `src/lib/email/bounce-parser.ts` (RFC 3464 + Outlook + OOO) - ☑ Cron worker `src/jobs/processors/imap-bounce-poller.ts` — reads IMAP\__ env, matches NDR recipient to recent document_sends, idempotent via `bounceDetectedAt`, fires `email_bounced` notification on hard/soft (skips OOO); state persisted to `system_settings.bounce_poller_state` (port_id=NULL). Wired into maintenance queue at `_/15 \* \* \* \*`. - ☑ UI banner on `/admin/sends` (admin sends-log) + `email_bounced` notification type - ☐ Interest-detail "Emails" tab — surface tab doesn't exist yet; bounce banner would live there when the tab lands (deferred to a wider emails-surface session) - ☐ Manual round-trip test against real bounced delivery - **Workspace activation:** set `IMAP_HOST=imap.gmail.com`, `IMAP_PORT=993`, `IMAP_USER=`, `IMAP_PASS=` in the worker env. App Passwords are generated at Account → Security → 2-Step Verification → App passwords. Google displays the password as **16 characters in 4 groups of 4 separated by spaces** (e.g. `abcd efgh ijkl mnop`). Per Google's own docs the spaces are visual only — paste the 16-char unbroken string into `.env`. The poller strips whitespace defensively (`src/jobs/processors/imap-bounce-poller.ts`) so a copy-paste with spaces still works. Bounces land in the envelope sender's mailbox (the SMTP user account), so pointing the poller at that single mailbox catches every automated-email bounce in one place. - ☑ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM x3) - ☑ FieldMap type definitions + Zod validators + page-count cross-validator - ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side `` with react-pdf, click-to-place markers, token picker from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. - ☑ 7.1 polish — unsaved-changes guard (beforeunload + visual "Unsaved changes" badge), responsive PDF width via ResizeObserver, required tokens unplaced indicator that reads `template.mergeFields`. - ☑ 7.2 drag-to-move with on-page clamping; 4 corner resize handles (NW/NE/SW/SE) with min-size + on-page clamping. - ☑ 7.2 multi-page navigation (page picker + per-page marker filter). - ☑ 7.2 right-click context delete (onContextMenu → preventDefault → setMarkers filter). - ☑ 7.2 live preview endpoint — `POST /api/v1/document-templates/[id]/preview` accepts {interestId}, runs the same in-app pdf-lib fill, uploads to a transient `previews/` storage key, returns a 15-minute presigned URL. - ☑ 7.2 new-PDF upload — `POST /api/v1/document-templates/[id]/source-pdf` accepts multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps `documentTemplates.sourceFileId` to the new files row. Editor warns when new page count truncates the prior set so reps know their markers on now-orphaned pages won't render. --- # 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 `` 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/.', 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 `` (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 (
); } ``` Add a link entry in `src/components/admin/admin-sections-browser.tsx`. ### B.6 — UI usage `` 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__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_` 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 + ``. New yacht tagged `yachts.source='eoi-generated'` and `yachts.source_document_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: , demoted: | 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_ // - '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 `` (or rename to Sheet per CLAUDE.md) File: `src/components/documents/eoi-generate-dialog.tsx` (existing). Per field (email, phone, address, yacht): 1. Replace `` with `` 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 (``) wrapping the existing ``. 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: " pointing at `/documents/` 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 { /* ... */ } export async function listReminderInbox(input: { portId: string; userId: string; filter: 'mine' | 'all_port'; }): Promise { /* ... */ } ``` ### 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 { // 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 " vs "Hello "). - 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 { 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 — ". 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 `` 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