Files
pn-new-crm/docs/MASTER-PLAN-2026-05-18.md
Matt f938847ed9 feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons
Phase 5 — luxury-port email tone (4 of 8 templates):
- portal-auth.tsx — activation + reset: "It's our pleasure to invite
  you to the {portName} client portal — your private space to review
  your berth, manage signed documents, and stay in touch with your
  sales liaison", sign-off "With warm regards, The {portName} Team",
  subjects "Welcome to {portName} — activate your client portal" /
  "Reset your {portName} portal password".
- inquiry-client-confirmation.tsx — "We've noted your enquiry, and a
  member of our team will be in touch shortly through your preferred
  channel", "should anything come to mind in the meantime", sign-off
  "With warm regards, The {portName} Sales Team".
- notification-digest.tsx — "Your {portName} update" header, "Here's
  what's waiting for you", "With warm regards, The {portName} Team".
- document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The
  {portName} team") rewritten to "With warm regards, The {portName} Team"
  with capitalised Team for consistency.
- Voice captured from old-CRM Nuxt repo
  (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
  server/utils/signature-notifications.ts) which already used "Dear",
  "Best regards", and collective sign-offs.

Remaining 4 templates (admin-email-change, crm-invite,
inquiry-sales-notification, residential-inquiry) + cross-port snapshot
tests queued as follow-up.

Phase 7.1 — PDF editor scaffold:
- New admin route /admin/templates/[id]/editor/page.tsx wired to a
  client-side <TemplateEditor>.
- Renders page 1 via react-pdf (worker URL pattern mirrors
  components/files/pdf-viewer.tsx); click-to-place markers in percent
  coordinates so a future page-size swap doesn't shift placements.
- Token picker over VALID_MERGE_TOKENS (sorted).
- Save persists overlayPositions via PATCH against the existing
  document_templates row; validator accepts the new field via
  fieldMapSchema from lib/templates/field-map.ts (no migration needed
  — overlay_positions JSONB column already exists).
- Outer/inner-body split + key-by-templateId remount avoids the
  in-render setState antipattern when seeding from server data.
- Add + delete markers supported. Multi-page, drag, resize, preview,
  new-PDF upload all defer to 7.2.

Per-entity polish:
- [+ Reminder] button on yacht / client / interest detail headers,
  threading defaultYachtId / defaultClientId / defaultInterestId so the
  ReminderForm opens with the entity pre-linked.
- [EOI] badge on yacht detail header when yacht.source === 'eoi-generated'
  (mirrors the contacts-editor pattern shipped in eaab149).

Phase 6 hardening:
- imap-bounce-poller strips whitespace from IMAP_PASS so Google
  Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work
  whether pasted with or without spaces. Confirmed via Google docs that
  the visual spaces are formatting only and must not reach the IMAP
  server.

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00

73 KiB
Raw Blame History

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 ~910h none
2 Deal-pulse signal expansion + admin config UI ~56h none
3 EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) ~11.5 weeks none
4 Reminders (reminder_note + standalone tasks + per-user TOD) ~34 days none
5 §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) ~57 days requires old-CRM reference
6 M-EM03 IMAP bounce-to-interest linking ~35 days none
7 PDF template editor (Phases 1+2) ~34 weeks none

Total visible work: ~78 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 (~34h)

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 (~12h)

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):
    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

~910 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 mappulse_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

~56h. 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 (~12 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

~11.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

~34 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

~57 days. The grunt is the tone rewrite (each template needs ~3045 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: " 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

~35 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 NULLArray<{ 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, ~12 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: ~12 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

~34 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 35 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 <ReminderForm> + Ship subtitle on <ReminderCard> + 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 <DealPulseChip>. 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 <YachtForm> 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 <ContactsEditor> + 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)
    • <ReminderCard> 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). <ReminderForm> 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)
    • ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
    • ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation
      • reset, inquiry-client-confirmation, notification-digest, document-signing sign-offs). Voice captured from old-CRM Nuxt repo server/utils/ signature-notifications.ts ("Dear X", "With warm regards, The {portName} Team").
    • ☐ Remaining 4 templates: admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry
    • ☐ Snapshot tests per template at port-nimara + 2nd test port
  • ◐ 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=<workspace-account>, IMAP_PASS=<app-password> 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)
    • ☑ FieldMap type definitions + Zod validators + page-count cross-validator
    • ☑ 7.1 scaffold — /admin/templates/[id]/editor/page.tsx + client-side <TemplateEditor> with react-pdf, click-to-place markers, token picker from VALID_MERGE_TOKENS, save via PATCH to overlayPositions. Page 1 only; add + delete markers supported.
    • ☐ 7.1 polish: unsaved-changes guard, responsive PDF width, "required tokens unplaced" indicator
    • ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with sample interest data, multi-page navigation, new-PDF upload (replace source while preserving field map)

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:

    {
      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:

    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:

    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 confirmationsrc/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.tsresolveSupplementalUrl(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 ~34h:

  • 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

-- 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:

// ─── 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):

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):

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:

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

-- 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:

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:

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:

{
  // 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: " 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

-- 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):

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:

// 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 35 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

-- 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:

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:

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 — ".

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

-- 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:

{
  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