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>
74 KiB
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 shippeddeal-pulse-trigger-audit.md— call-site inventory for §1.2 signal expansioneoi-documenso-field-mapping.md— token → AcroForm map for §1.3 EOI overridesberth-recommender-and-pdf-plan.md— prior PDF infrastructure context
This is the single source of truth for everything outstanding. Each phase is self-contained: a fresh session can pick up any phase and ship it without re-reading the others. Phases are ordered by dependency + ship-size; bigger features can be split across sessions inside their own phase boundary.
Sequencing summary
| # | Phase | Effort | Depends on |
|---|---|---|---|
| 1 | Documenso completion (7 → 2 → 5) + Supplemental form per-port | ~9–10h | none |
| 2 | Deal-pulse signal expansion + admin config UI | ~5–6h | none |
| 3 | EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) | ~1–1.5 weeks | none |
| 4 | Reminders (reminder_note + standalone tasks + per-user TOD) | ~3–4 days | none |
| 5 | §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) | ~5–7 days | requires old-CRM reference |
| 6 | M-EM03 IMAP bounce-to-interest linking | ~3–5 days | none |
| 7 | PDF template editor (Phases 1+2) | ~3–4 weeks | none |
Total visible work: ~7–8 weeks of focused development at 1 phase at a time. Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are medium; Phase 7 is the long one.
Phase 1 — Documenso completion + Supplemental form per-port
Reference: POST-AUDIT-SPEC-2026-05-18.md §3 (Supplemental form) + §4 (Documenso 2/5/7).
Bundled because both touch admin UIs under /admin/documenso/ and /admin/email/.
1.1 Documenso Phase 7 — Project Director RBAC (~1h)
Goal: When a Documenso event arrives for the developer or approver signer, also notify the linked CRM user in their inbox.
Scope in:
- Add "Linked to CRM user" dropdowns to
/admin/documenso/page.tsxfor the existingdeveloper_user_idandapprover_user_idsystem_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 adocumenso:signednotification routed to the linked CRM user.
Scope out:
- Permissions changes (using existing notification routing).
- New audit*log actions (existing
documenso_webhook*\*covers it).
Data: No schema change. system_settings.developer_user_id and
approver_user_id already exist.
API: No new routes. Reuses /api/v1/admin/users/picker.
UI: Two new fields in the Documenso admin page (left column, below the existing developer name/email pair).
Acceptance:
- Selecting a CRM user fills the name + email fields automatically.
- Test webhook fires: linked user sees a notification in their inbox.
- Unlink (select "None"): no notification fires.
Test plan:
- Unit: webhook router resolves user_id → notification target.
- E2E (smoke): admin can link/unlink users; UI updates persist.
1.2 Documenso Phase 2 — Webhook handler enhancement (~3–4h)
Goal: Sequential signing fires "your turn" emails to signer N+1 when signer N completes; on COMPLETED, distribute signed PDF to all CC emails; tighten idempotency.
Scope in:
- Cascading invite: in
handleDocumentSigned, look up the next pending recipient (nextrecipientIdin order withsigned_at IS NULL) and queue asendSigningInvitationfor that signer. Sequential mode only (checksigning_order). - On-completion CC distribution: in
handleDocumentCompleted, after the PDF is downloaded and saved to files, email eachdocuments.completion_cc_emailsrow with the signed PDF as a download link (signed URL, 24h TTL). - Token-based matching: prefer
signing_tokenover email for webhook → recipient resolution; falls back to email-only when token is absent. - Idempotency: composite unique constraint
(documensoDocumentId, recipientEmail, eventType)ondocumentEvents; 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, addunique(documensoDocumentId, recipientEmail, eventType). Migration is reversible — the body-hash column stays.
API: No new routes. Internal webhook handler only.
UI: No change.
Acceptance:
- Sequential 3-signer doc: signer 1 signs → signer 2 receives invite email; signer 2 signs → signer 3 receives invite; signer 3 signs → COMPLETED fires and CC list gets the signed PDF link.
- Duplicate webhook retries are no-ops (composite key blocks insert).
- Parallel-mode doc: no cascade (all signers got their invite at send).
Test plan:
- Integration: mock 3-signer sequential webhook stream, assert email count + distribution.
- Integration: COMPLETED webhook with CC list, assert link email per CC.
- Unit: idempotency composite key rejects duplicates.
1.3 Documenso Phase 5 — Embedded signing URL verification (~1–2h)
Goal: Confirm the marketing site's /sign/<type>/<token> route
handles every signer-role × documentType combo; tighten role-specific
copy in invitation emails.
Scope in:
- Audit
signerMessagesmap insrc/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.tsalready).
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_urlkey (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.tsxas a single text input with help hint "Leave blank to use the built-in CRM page." - CRM fallback route
/supplemental/[token]/page.tsx: confirm it still renders (already exists). Add dual-mode "If you don't see your details, contact your rep" hint.
Scope out: Token format changes; the existing token scheme works for both modes.
Data: New system_settings key only.
API: No new routes.
UI: One new input on /admin/email/page.tsx.
Acceptance:
- With URL configured: email link points at marketing site with token query param.
- With URL blank: email link points at CRM route.
- Token roundtrips through both modes successfully.
Test plan:
- Unit: link resolver returns expected URL for both cases.
- Integration: send-out flow with each config variant.
Phase 1 total effort
~9–10 hours. Ships as 4 commits in a single PR.
Phase 2 — Deal-pulse signal expansion + admin config UI
Reference: deal-pulse-trigger-audit.md (call-site inventory).
Goal
The deal-pulse chip currently shows momentum (stage advancement, time-in-stage) but ignores high-value pipeline signals. Expand the signal set + give admins per-port control over which signals fire, what labels they show, and tier thresholds.
Signal additions
Positive (brighten chip):
eoi_sent— fires when EOI status transitions tosent. Already a call-site indocuments.service.tsfor stage auto-advance; hook apulseSignals.push({ kind: 'eoi_sent', at: now })next to it.deposit_received— fires when an invoice withpurpose = 'deposit'flips tostatus = 'paid'. Hook ininvoices.service.ts:markPaid.contract_signed— fires when adocumentsrow withtemplateType = 'contract'flips tostatus = 'completed'. Hook in webhook handlerhandleDocumentCompleted.
Negative (darken chip):
document_declined— fires when any doc on the interest flips tostatus IN ('declined', 'rejected'). Hook inhandleDocumentRejected(already exists from Phase A).reservation_cancelled— fires when areservationsrow flips tostatus = 'cancelled'. Hook inreservations.service.ts.berth_sold_to_other— fires when the interest's primary berth gets linked to a different completed interest. Hook ininterest-berths.service.ts:upsertInterestBerthwhen 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/):
- Master toggle (
pulse_enabled, defaulttrue): off → chip hides on every interest list/detail surface. - Per-signal toggles — checkbox per signal, all default on. Stored
as
pulse_signal_<name>_enabled. - Label rename map —
pulse_label_<key>text fields for: "Hot", "Quiet", "At Risk", "Critical", "EOI sent", "Deposit paid", "Contract signed", "Declined", "Reservation cancelled", "Berth resold". Empty value = use built-in default. - Cadence threshold inputs — three numeric inputs for the day thresholds above.
- 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 truepulse_signal_<name>_enabled boolean default truefor each signalpulse_label_<key> textfor each renamable labelpulse_cadence_warning_days int default 7pulse_cadence_critical_days int default 21pulse_cadence_terminal_days int default 45
No new tables. The pulse signal computation is read-time from existing
data (documents, invoices, reservations, interest_berths) — no
persistent signal log needed.
API additions
GET /api/v1/admin/pulse/settings— read current config.PUT /api/v1/admin/pulse/settings— write config (Zod-validated).
Existing pulse computation in src/lib/services/deal-pulse.service.ts:
- Extend
computePulseFor(interestId)to read per-port settings + new signal sources. - Cache settings per-port for the request lifetime.
UI
<DealPulseChip>already exists; extend label resolution to use per-port custom labels with fallback to built-ins.- New admin page (1 file, ~250 LOC).
Acceptance
- Each signal fires when the linked event happens (verified via integration test triggering the upstream event).
- Master toggle off → chip absent on every surface.
- Per-signal toggle off → signal absent from chip even when event fires.
- Custom label "Hot" → "Active" renders correctly.
- Cadence threshold 7 → 14 → 30 → tier transitions match the new thresholds.
Test plan
- Unit per signal: trigger upstream event, assert pulse output contains the signal.
- Unit cadence tier: insert interests with stage-entered timestamps at boundary ages, assert tier classification.
- Integration: admin page round-trips config save + read.
Effort
~5–6h. One PR.
Phase 3 — EOI field overrides
Reference: POST-AUDIT-SPEC-2026-05-18.md §1 (base spec) + user
clarifications below.
Goal
When generating an EOI, rep can override pre-filled fields (contact info, addresses, yacht name + dimensions) from a dropdown of every known value for that channel. Manual entries persist as tracked secondary values; future EOIs can pick them from the dropdown. Yacht overrides spawn a new yacht record linked to the same interest/client.
User clarifications captured
- Multi-value contacts: Email and phone fields render as
dropdowns of every
client_contactsrow for that channel. - Per-EOI vs persistent override:
- "Use only for this EOI" → write to
documents.override_*cols, don't touchclient_contacts. - "Save as new contact" → insert
client_contactsrow withis_primary=false,source='eoi-custom-input'. - "Set as default for future documents" → promote to
is_primary=true, demote the prior primary.
- "Use only for this EOI" → write to
- Badge label: Use
[EOI]not[EOI Only](future docs may reuse the value). - 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 withyachts.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 ineoi-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/yachtsextension: acceptssource+source_document_idfields (admin-only or system-only).- EOI generate endpoint
(
/api/v1/document-templates/[id]/generate-and-sign) accepts per-field override params; persists todocuments.override_*cols or spawnsclient_contactsrows 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_contactsrow visible in the client detail panel. - "Set as default" promotes to primary and demotes the prior.
- Yacht spawn: new yacht visible under both client and interest
with the
[EOI]badge; original yacht unchanged. - Audit log records each override action with the source doc id.
Test plan
- Unit per scenario: per-EOI override, save-as-new, promote-to-primary, yacht spawn.
- Integration: full EOI generate flow with overrides, assert resulting doc + side-effects.
- E2E (smoke): rep generates EOI with a custom email + new yacht; artifacts visible on detail pages.
Effort breakdown (across sessions)
- Session 3a (~3 days): Schema migration + audit_actions + API endpoints for contact promote + document override persistence. Tests for service layer.
- Session 3b (~3 days): UI — EOI form dropdowns, "save as new" inline flow, "set as default" toggle, badges on client/yacht detail.
- Session 3c (~2 days): Yacht spawn flow — Sheet + YachtForm reuse + interest auto-link. Integration tests + E2E smoke.
- Session 3d (~1–2 days): Polish — audit log surfacing in audit-log UI, badges/labels in notification copy, documentation.
Risks
- Schema migration is FK-heavy. Run
pnpm db:generatecarefully; the partial unique index onclient_contacts.is_primary(one primary per channel) must not be broken by the promote endpoint. - The promote endpoint is a two-step write that must be transactional
(demote prior primary, then promote target) — wrap in
db.transaction.
Effort
~1–1.5 weeks. Split into 4 sub-sessions per the breakdown above.
Phase 4 — Reminders
Reference: POST-AUDIT-SPEC-2026-05-18.md §2.
Goal
Reps can: (a) attach a follow-up note to interest cadences, (b) create standalone tasks not tied to an entity, (c) assign tasks to other reps, (d) configure their default firing time-of-day with per-row override.
Schema additions
interests.reminder_note text NULL— surfaced in notification body and inbox row.user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'.reminders.fired_at timestamptz NULL— drives worker idempotency.- No new tables.
reminderstable already has title, note, priority, due_at, assigned_to, snoozed_until, google_calendar_event_id.
API additions
POST /api/v1/reminders— extend to accept nulllinked_entityfor standalone tasks.PATCH /api/v1/me/profile— extend to acceptdigest_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, setsfired_at. Wrap inpg_advisory_xact_lockper-port to avoid duplicate fires on parallel workers.
Acceptance
- Standalone task: created from inbox, no linked entity, fires at the chosen TOD.
- Per-interest cadence with note: surfaces in notification body + inbox row.
- Assign to another rep: assignee sees task in their inbox; chip shows the assignor's name; original creator sees an assignee chip.
- Default TOD set to 14:00 → new reminders default to 14:00; per-row override to 09:30 wins.
- Worker idempotency: same reminder fires once even if 2 worker processes race.
Test plan
- Unit: cron scan picks up due reminders; fired_at gates re-firing.
- Integration: dialog → POST → DB row visible in inbox.
- E2E: rep creates standalone task from inbox; appears for assignee.
Effort
~3–4 days. One PR.
Phase 5 — §11.3 Email-copy refactor
Reference: old-CRM Nuxt repo at
/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
(notable: server/utils/email.ts, server/tasks/process-sales-emails.ts,
components/EmailComposer.vue).
Goal
Modernize email tone to luxury-port voice; audit per-port branding chain (logo, signature block, footer copy); ensure every automated email uses the per-port branded shell.
Scope in
- Tone pass: Rewrite every template in
src/lib/email/templates/using the old-CRM templates as voice reference (open them, capture cadence + phrasing, port to current React-email or HTML-string format). - Branding chain audit: Walk every sender callsite (
sendEmailinsrc/lib/email/), confirm port-specific logo URL and footer get threaded through viacfg.portLogoUrl,cfg.portFooter. Fix any hard-codeds3.portnimara.com/logo.pngstrings (current templates reference this directly per CLAUDE.md note). - New templates if missing: signing-invitation cascade, supplemental form, reminder digest. Match the existing tone after the rewrite.
Scope out
- Localization. Current templates are EN-only; defer i18n to Phase C unless a port specifically requests another language.
- New triggers. Same set of emails as today, better copy + branding.
Data
None. Settings keys for branding already exist (port_logo_url,
port_email_footer).
API
None.
UI
None (admin email panel already exposes the settings keys).
Acceptance
- Each template renders correctly for port-nimara AND a 2nd test port with different logo + footer.
- Old-CRM reference quotes inline in PR description for traceability.
- No remaining hard-coded port branding strings (grep
portnimara.com, expect zero matches outside settings defaults).
Test plan
- Snapshot tests per template at the rendering layer.
- Manual: send a test email per template to a real inbox, confirm branding renders.
Effort
~5–7 days. The grunt is the tone rewrite (each template needs ~30–45 min of focused copywriting + review). One PR with the template files; CI snapshot tests gate it.
Phase 6 — M-EM03 IMAP bounce-to-interest linking
Reference: Phase 7 §14.9 in the original system spec.
Goal
When an outbound sales email bounces (NDR returned via IMAP), match
the bounce to the originating document_sends row and surface a
warning on the linked interest's email tab.
Scope in
- Bounce parser in
src/lib/email/bounce-parser.ts— extract original recipient + bounce reason from common NDR formats (Gmail, Outlook, Postfix, Exchange). - Cron job in
src/jobs/processors/imap-bounce-poller.ts— polls configuredIMAP_*mailbox for new bounces, matches againstdocument_sends.recipient_email + sent_at, updatesdocument_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/emailsprojection extends to surface bounce fields.
UI
<InterestEmailsTab>row gets a red border + "Bounced: " banner whenbounce_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_TOoff.
Effort
~3–5 days. Parser fixtures are the longest tail.
Phase 7 — PDF template editor (Phases 1+2)
Reference: berth-recommender-and-pdf-plan.md (background +
infrastructure context).
Goal
A web-based PDF template editor that lets admins:
- Phase 1: View an existing PDF template, click on a page region to drop a merge-field marker, save the field map.
- Phase 2: Edit existing fields (move, resize, delete), upload a new PDF (replacing the source), live-preview the AcroForm fill.
This replaces the current "edit the template PDF in Acrobat, re-upload" workflow with an in-app editor.
Phase 7.1 — Read + place (Phase 1, ~2 weeks)
Scope in:
- New admin page
/admin/templates/[id]/editor/page.tsx. - PDF viewer using
react-pdf(already in deps for invoice rendering). - Field marker overlay: click on a region → enter merge-field token
name → marker persists in
document_templates.field_map JSONB. - Token autocomplete from
VALID_MERGE_TOKENS(src/lib/templates/merge-fields.ts). - Save endpoint:
PUT /api/v1/document-templates/:id/field-map.
Scope out:
- Editing existing AcroForm fields (separate workflow).
- Multi-page navigation (Phase 1 = page 1 only).
- Conditional fields, signatures, repeating sections.
Data:
document_templates.field_map JSONB NULL—Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>.
API:
PUT /api/v1/document-templates/:id/field-map.
UI: Full editor page; uses Sheet for token picker side-panel.
Acceptance:
- Admin places 3 markers on a sample PDF; saves; reopens; markers persist at the right coords.
- Generating a doc from the template fills the AcroForm at those coords with the merge-field values.
Effort: ~2 weeks.
Phase 7.2 — Edit + preview (Phase 2, ~1–2 weeks)
Scope in:
- Drag-to-move existing markers.
- Drag-corner-to-resize markers.
- Delete marker via right-click → "Remove field".
- Live preview pane (right side) showing the AcroForm fill with sample data from a chosen interest.
- Multi-page navigation (page picker top-left).
- New-PDF upload: replace the source file while preserving the field map (warn if coord ranges shift).
Scope out:
- Conditional fields / signatures (defer to Phase 3, unscoped).
Data: No new schema; reuses field_map JSONB.
API:
POST /api/v1/document-templates/:id/preview— accepts an interest ID, returns a presigned URL to a transient preview PDF.
UI: Editor extends — page picker, preview pane, drag handlers, right-click context menu.
Acceptance:
- Drag a marker → save → reopen → marker is at the new coords.
- Resize marker → field rendering box matches new dims.
- Upload replacement PDF → field map preserved; warning shown if page count changed.
- Live preview reflects current field map within 2s of edits.
Effort: ~1–2 weeks.
Test plan (both phases)
- Unit: field-map serialization + coord persistence.
- Integration: PUT field-map → re-GET → exact roundtrip.
- E2E: admin places marker, generates doc, signed PDF has value at expected coord (within tolerance).
Risks
react-pdfperformance on large PDFs — measure on 50-page samples before committing to the page-picker UX.- Coord system: PDF uses bottom-left origin; viewer uses top-left. Wrap a single coord-converter to avoid scattered conversions.
Effort total
~3–4 weeks for both phases. Highest cost in the plan; queue last.
Execution discipline
Each session that picks up a phase MUST:
- Read this doc + the referenced companion before opening any source file.
- Confirm the issue still exists — re-grep the call sites listed in the phase to ensure prior work hasn't already fixed something.
- Open a single PR per phase unless explicitly split into sub-sessions (Phase 3 is split into 3a/3b/3c/3d).
- 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). - Update this doc — mark the phase ☑ in the sequencing summary
table; capture any spec drift in a
## Implementation notesaddendum at the end of the phase.
Open questions deferred to phase-start
These don't block the plan but should be resolved when the relevant phase starts:
- Phase 1: Phase 5's nginx config — does the ops repo own this file, or does this CRM repo? (Resolve before Phase 1.3.)
- Phase 2: Should label rename support multi-language, or is EN-only acceptable for the per-port admin? (Recommend EN-only; i18n is Phase C work.)
- Phase 3: Should
[EOI]badges fade after a TTL or persist forever? (Recommend forever — rep chose this label deliberately.) - Phase 5: When the old-CRM tone reference is opened, capture 3–5 representative templates and quote them in the PR description for reviewer traceability.
- Phase 7: Confirm
react-pdfperformance 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>+listRemindersfilter +getReminderyacht relation join. - ☑ Phase 2 risk-signal data wiring — derivation pass in
getInterestById(3 parallel queries) populates the 3 risk-signal dates fromdocument_events/berth_reservations/ cross-interestinterest_berths. Chosen over new schema columns; documented in CLAUDE.md. - ☑ Phase 6 cron + UI —
imap-bounce-poller.tsworker wired into maintenance queue at*/15 * * * *; matches NDRs to recentdocument_sendsrows, firesemail_bouncednotification on hard/soft; admin/admin/sendspage now shows bounce badge + reason banner. - Quality gates: 1374/1374 vitest pass,
tsc --noEmitclean,pnpm lintzero 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 againstdocument_events(rejected/declined),berth_reservations(status='cancelled'), and otherwoninterests sharing a berth viainterest_berths. Returns the 3 dates on the API response;interest-detail-headerthreads 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 verbseoi_field_override+promote_to_primaryeoi_spawn_yachtformalised insrc/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, stampssource='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 viapromoteContactToPrimary);[EOI]badge on non-primary contact rows in<ContactsEditor>+ on yacht detail header whenyacht.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) - ☑
listRemindersfilters by query.yachtId;getReminderjoins yacht relation - ☑ Worker
processOverdueRemindersclaims due rows viaUPDATE...RETURNINGwithfired_at IS NULLrace-safe gate, so parallel workers can't double-fire the same reminder. - ☑
user_profiles.preferences.digestTimeOfDaypicker 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 viabounceDetectedAt, firesemail_bouncednotification on hard/soft (skips OOO); state persisted tosystem_settings.bounce_poller_state(port_id=NULL). Wired into maintenance queue at_/15 \* \* \* \*. - ☑ UI banner on
/admin/sends(admin sends-log) +email_bouncednotification 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 fromVALID_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]/previewaccepts {interestId}, runs the same in-app pdf-lib fill, uploads to a transientpreviews/storage key, returns a 15-minute presigned URL. - ☑ 7.2 new-PDF upload —
POST /api/v1/document-templates/[id]/source-pdfaccepts multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swapsdocumentTemplates.sourceFileIdto 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 keysdocumenso_developer_user_id+documenso_approver_user_idexist (src/lib/settings/registry.ts:116, 162). Admin UI renders them via<RegistryDrivenForm sections={['documenso.signers']}>with theuser-selectfield 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 adocument_signing_your_turnnotification in their inbox. - 1.2 Cascading invite to next signer → already in code
(
sendCascadingInviteForNextSigneratdocuments.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.tsmints tokens for the CRM-hosted/supplemental/[token]route. → Full implementation needed.
A.2 — Supplemental form per-port: per-file change list
-
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', }, -
src/lib/services/port-config.ts— Map the new key:supplementalFormUrl: 'supplemental_form_url', -
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}`; -
Admin page — Re-render via
<RegistryDrivenForm sections={['email.general']} />(or new section). No JSX edit needed if the section key matches an existing card. -
Fallback route confirmation —
src/app/(portal)/public/supplemental-infostays as-is. Adds copy "If you don't see your details, contact your rep."
A.3 — Test plan additions
- Vitest unit:
supplemental-form-link.test.ts—resolveSupplementalUrl(cfg, raw)returns external URL when set, CRM URL when blank. - Vitest integration:
supplemental-email-send.test.ts— mocks a port withsupplemental_form_urlset; assert sent email body contains the external URL. - Playwright (smoke): admin can set + clear the URL; UI persists.
A.4 — Phase 1 effort revision
Given 1.1 + 1.2 are already shipped, real remaining work is ~3–4h:
- 1.3 signing-invitation copy audit: ~1h
- 1.3 nginx CORS: 5min if it's already documented, ~30min if not
- 1.4 supplemental form: ~2h
- Tests + smoke: ~30min
Appendix B — Phase 2 (Deal-pulse signals + admin config UI)
B.1 — Schema migration SQL
-- 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:
- Reads
pulse_enabled→ returns{ visible: false }early if off. - Reads per-signal toggles + label overrides into a memoized config.
- Reads cadence-tier thresholds.
- Computes tier from
stage_entered_atagainst thresholds. - 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:
- Accept the full
PulseResult(not just the tier). - Hide entirely when
visible: false. - 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>_atcolumn updated. - Unit per signal toggling: With master toggle off →
computePulseForreturns{ visible: false }. With per-signal toggle off → signal absent fromsignals[]. - Unit per cadence: Interest with
stage_entered_atat 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)
- Contact-channel dropdowns show every
client_contactsrow for that channel, defaulting to the row withis_primary=true. - 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_contactsrow,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.
- Neither ticked → write to
- Badge label:
[EOI](not[EOI Only]). - Yacht overrides: spawn new yacht via inline Sheet +
<YachtForm>. New yacht taggedyachts.source='eoi-generated'andyachts.source_document_id=<doc-id>. Original yacht untouched. - Audit trail: every action emits
audit_logrow with actioneoi_field_override,promote_to_primary, oreoi_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):
- Replace
<Input>with<Combobox>populated from multi-value rows. - Below: 2 checkboxes:
[ ] Use only for this EOI[ ] Set as default for future docs
- 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 wheresource === '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 whenyacht.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
src/components/reminders/reminders-inbox.tsx:[+ New task]button in toolbar.src/components/interests/interest-detail-header.tsx:[+ Task]button next to existing Reminders panel.- Mirror for clients/berths/yachts detail pages.
D.6 — Settings page
src/app/(dashboard)/[portSlug]/settings/notifications/page.tsx
(or wherever user-level prefs live): add a time picker bound to
user_profiles.digest_time_of_day via PATCH /api/v1/me/profile.
D.7 — Sub-session breakdown
- 4a — Schema + service + worker (1.5 days)
- 4b — Dialog component + 4 mount points (1.5 days)
- 4c — Settings page time-of-day picker + tests (0.5 days)
- 4d — Integration + E2E (0.5 days)
Appendix E — Phase 5 (Email-copy refactor) — comprehensive
E.1 — Old-CRM reference location (captured)
/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
Notable files:
server/utils/email.ts— Nodemailer wrapper with subject/html shape.server/tasks/process-sales-emails.ts— automated send-out cadence.components/EmailComposer.vue— UI tone reference.components/EmailCommunication.vue— body markdown handling.
Step 1 of execution: open these three files, capture 3–5 representative template strings, quote in PR description for reviewer traceability.
E.2 — Templates to refactor (per-file)
Current src/lib/email/templates/:
portal-auth.ts— activation + reset (already branded, voice pass needed)signing-invitation.ts— voice-pass + role-specific copy completeness checksigning-completion.ts— voice-passsupplemental-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-passchange-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.tsasserts 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):
- Find
document_sendsrow whererecipient_email = parsed.originalRecipient AND sent_at > now() - interval '7 days' AND bounce_status IS NULL. - If found: update
bounce_status+bounce_reason+bounce_detected_at, fire notification to the sender (user_id from document_sends.sent_by_user_id). - 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
coordTransformerutility — never scatter conversions. - Drag handles:
react-draggable(small footprint) for marker movement. Resize viareact-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 ascreateTemplateSchema). 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:
- Compute MD5 of old + new PDFs — block upload if identical.
- 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.").
- 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-pdfworker 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-pdfperforms 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.pdfAcroForm 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]/editorwith port resolution via the existing port-context middleware.
Cross-phase risks + considerations
-
Schema migrations are FK-heavy across Phases 3, 4, 6, 7. Run
pnpm db:generateafter each, inspect the generated SQL by eye, apply to dev DB, restartnext dev(per CLAUDE.md pool-cache note). -
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. -
Per-port admin pages multiply. After all phases ship, this adds:
/admin/pulse,/admin/templates/[id]/editor. Confirm the<AdminSectionsBrowser>index covers them. -
Worker process additions. Phase 4 (reminders) and Phase 6 (bounces) both add cron-style jobs. Confirm
Dockerfile.workerwakes them up; capture metrics for monitoring. -
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.
-
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 runpasses.pnpm tsc --noEmitpasses.pnpm lintpasses.- For phases touching middleware/env/build config:
pnpm buildpasses. - 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