Files
pn-new-crm/docs/MASTER-PLAN-2026-05-18.md
Matt ef0dc5abc4 feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work
Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
  canonical address form (line1/line2/city/state/postal + ISO
  subdivision + CountryCombobox). Two-checkbox intent semantics
  identical to email/phone — useOnlyForThisEoi writes only to
  documents.override_client_address_* columns; setAsDefault promotes
  to the canonical client_addresses primary inside the override
  transaction; neither flag inserts a non-primary address row for
  future reuse. eoi-context route now returns available.addresses so
  the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
  BEFORE generateAndSign creates the document row, so source_document_id
  stayed NULL. Mirrored the bounded-recent backfill pattern from
  contacts into persistDocumentOverrides for both client_addresses and
  yachts (every row inserted in the last 60s with NULL source_document_id
  and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
  promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
  dropdown + get human labels in the card view.

Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
  open reminders for an entity. Mounted on Overview tab of yacht /
  client / interest detail. Empty state hints at the header button
  rather than duplicating it.

Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
  residential-inquiry — voice + sign-off match the 4 shipped earlier
  ("Dear X", "With warm regards, The {portName} Team", sentence-case
  subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
  set up to catch port-name leaks; templates are correct in review.

Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
  badge), ResizeObserver-driven responsive PDF width, required-tokens-
  unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
  runs the in-app pdf-lib fill against the supplied interest, uploads
  to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
  takes multipart FormData, magic-byte verifies %PDF-, parses page
  count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
  warns when the new page count truncates the prior set.

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 17:09:19 +02:00

74 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 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=<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 x3)
    • ☑ 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.
    • ☑ 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 <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