Phase 5 — luxury-port email tone (4 of 8 templates):
- portal-auth.tsx — activation + reset: "It's our pleasure to invite
you to the {portName} client portal — your private space to review
your berth, manage signed documents, and stay in touch with your
sales liaison", sign-off "With warm regards, The {portName} Team",
subjects "Welcome to {portName} — activate your client portal" /
"Reset your {portName} portal password".
- inquiry-client-confirmation.tsx — "We've noted your enquiry, and a
member of our team will be in touch shortly through your preferred
channel", "should anything come to mind in the meantime", sign-off
"With warm regards, The {portName} Sales Team".
- notification-digest.tsx — "Your {portName} update" header, "Here's
what's waiting for you", "With warm regards, The {portName} Team".
- document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The
{portName} team") rewritten to "With warm regards, The {portName} Team"
with capitalised Team for consistency.
- Voice captured from old-CRM Nuxt repo
(/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
server/utils/signature-notifications.ts) which already used "Dear",
"Best regards", and collective sign-offs.
Remaining 4 templates (admin-email-change, crm-invite,
inquiry-sales-notification, residential-inquiry) + cross-port snapshot
tests queued as follow-up.
Phase 7.1 — PDF editor scaffold:
- New admin route /admin/templates/[id]/editor/page.tsx wired to a
client-side <TemplateEditor>.
- Renders page 1 via react-pdf (worker URL pattern mirrors
components/files/pdf-viewer.tsx); click-to-place markers in percent
coordinates so a future page-size swap doesn't shift placements.
- Token picker over VALID_MERGE_TOKENS (sorted).
- Save persists overlayPositions via PATCH against the existing
document_templates row; validator accepts the new field via
fieldMapSchema from lib/templates/field-map.ts (no migration needed
— overlay_positions JSONB column already exists).
- Outer/inner-body split + key-by-templateId remount avoids the
in-render setState antipattern when seeding from server data.
- Add + delete markers supported. Multi-page, drag, resize, preview,
new-PDF upload all defer to 7.2.
Per-entity polish:
- [+ Reminder] button on yacht / client / interest detail headers,
threading defaultYachtId / defaultClientId / defaultInterestId so the
ReminderForm opens with the entity pre-linked.
- [EOI] badge on yacht detail header when yacht.source === 'eoi-generated'
(mirrors the contacts-editor pattern shipped in eaab149).
Phase 6 hardening:
- imap-bounce-poller strips whitespace from IMAP_PASS so Google
Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work
whether pasted with or without spaces. Confirmed via Google docs that
the visual spaces are formatting only and must not reach the IMAP
server.
Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 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)- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
- ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation
- reset, inquiry-client-confirmation, notification-digest, document-signing
sign-offs). Voice captured from old-CRM Nuxt repo
server/utils/ signature-notifications.ts("Dear X", "With warm regards, The {portName} Team").
- reset, inquiry-client-confirmation, notification-digest, document-signing
sign-offs). Voice captured from old-CRM Nuxt repo
- ☐ Remaining 4 templates: admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry
- ☐ Snapshot tests per template at port-nimara + 2nd test port
- ◐ Phase 6 — IMAP bounce-to-interest linking (
9f57868+ session 2026-05-18 PM)- ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends
- ☑ Parser library
src/lib/email/bounce-parser.ts(RFC 3464 + Outlook + OOO) - ☑ Cron worker
src/jobs/processors/imap-bounce-poller.ts— reads IMAP__ env, matches NDR recipient to recent document_sends, idempotent 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)- ☑ 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. Page 1 only; add + delete markers supported. - ☐ 7.1 polish: unsaved-changes guard, responsive PDF width, "required tokens unplaced" indicator
- ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with sample interest data, multi-page navigation, new-PDF upload (replace source while preserving field map)
Detailed Implementation Appendix
This appendix expands every phase with per-file change lists, schema migration SQL skeletons, API request/response shapes, and component breakdowns. Anything ambiguous in the phase summaries above is resolved here. Read this in conjunction with the phase header.
Appendix A — Phase 1 (Documenso completion + Supplemental form)
A.1 — Status of each sub-phase against existing code
A grep + read pass at the time of writing this appendix confirmed:
- 1.1 Project Director RBAC notification → already in code
(
src/lib/services/documents.service.ts:1268-1300). Registry 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