Files
pn-new-crm/docs/MASTER-PLAN-2026-05-18.md

1929 lines
74 KiB
Markdown
Raw Normal View History

# Master Implementation Plan — Post-Audit Remediation & Feature Expansion
**Created:** 2026-05-18
**Status:** Active — sequenced across multiple sessions
**Companion docs:**
- `POST-AUDIT-SPEC-2026-05-18.md` — original design decisions (this doc extends + supersedes)
- `AUDIT-FIX-WAVE-2026-05-18.md` — what already shipped
- `deal-pulse-trigger-audit.md` — call-site inventory for §1.2 signal expansion
- `eoi-documenso-field-mapping.md` — token → AcroForm map for §1.3 EOI overrides
- `berth-recommender-and-pdf-plan.md` — prior PDF infrastructure context
This is the single source of truth for everything outstanding. Each
phase is self-contained: a fresh session can pick up any phase and ship
it without re-reading the others. Phases are ordered by dependency +
ship-size; bigger features can be split across sessions inside their
own phase boundary.
---
## Sequencing summary
| # | Phase | Effort | Depends on |
| --- | ---------------------------------------------------------------------------- | ------------ | -------------------------- |
| 1 | Documenso completion (7 → 2 → 5) + Supplemental form per-port | ~910h | none |
| 2 | Deal-pulse signal expansion + admin config UI | ~56h | none |
| 3 | EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) | ~11.5 weeks | none |
| 4 | Reminders (reminder_note + standalone tasks + per-user TOD) | ~34 days | none |
| 5 | §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) | ~57 days | requires old-CRM reference |
| 6 | M-EM03 IMAP bounce-to-interest linking | ~35 days | none |
| 7 | PDF template editor (Phases 1+2) | ~34 weeks | none |
**Total visible work:** ~78 weeks of focused development at 1 phase at a time.
Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are
medium; Phase 7 is the long one.
---
## Phase 1 — Documenso completion + Supplemental form per-port
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §3 (Supplemental form) + §4 (Documenso 2/5/7).
Bundled because both touch admin UIs under `/admin/documenso/` and `/admin/email/`.
### 1.1 Documenso Phase 7 — Project Director RBAC (~1h)
**Goal:** When a Documenso event arrives for the developer or approver
signer, also notify the linked CRM user in their inbox.
**Scope in:**
- Add "Linked to CRM user" dropdowns to `/admin/documenso/page.tsx` for
the existing `developer_user_id` and `approver_user_id` system_settings.
- Auto-fill name/email when a user is selected (read via existing
`/api/v1/admin/users/picker`).
- Webhook handler additions in `src/app/api/webhooks/documenso/route.ts`:
when an event matches the developer/approver, also emit a
`documenso:signed` notification routed to the linked CRM user.
**Scope out:**
- Permissions changes (using existing notification routing).
- New audit*log actions (existing `documenso_webhook*\*` covers it).
**Data:** No schema change. `system_settings.developer_user_id` and
`approver_user_id` already exist.
**API:** No new routes. Reuses `/api/v1/admin/users/picker`.
**UI:** Two new fields in the Documenso admin page (left column,
below the existing developer name/email pair).
**Acceptance:**
- Selecting a CRM user fills the name + email fields automatically.
- Test webhook fires: linked user sees a notification in their inbox.
- Unlink (select "None"): no notification fires.
**Test plan:**
- Unit: webhook router resolves user_id → notification target.
- E2E (smoke): admin can link/unlink users; UI updates persist.
### 1.2 Documenso Phase 2 — Webhook handler enhancement (~34h)
**Goal:** Sequential signing fires "your turn" emails to signer N+1
when signer N completes; on COMPLETED, distribute signed PDF to all CC
emails; tighten idempotency.
**Scope in:**
- Cascading invite: in `handleDocumentSigned`, look up the next
pending recipient (next `recipientId` in order with `signed_at IS
NULL`) and queue a `sendSigningInvitation` for that signer.
Sequential mode only (check `signing_order`).
- On-completion CC distribution: in `handleDocumentCompleted`, after
the PDF is downloaded and saved to files, email each
`documents.completion_cc_emails` row with the signed PDF as a
download link (signed URL, 24h TTL).
- Token-based matching: prefer `signing_token` over email for
webhook → recipient resolution; falls back to email-only when
token is absent.
- Idempotency: composite unique constraint
`(documensoDocumentId, recipientEmail, eventType)` on `documentEvents`;
replaces the current body-hash dedup.
**Scope out:**
- Parallel-mode invite flow (already covered by initial distribution).
- Self-hosted PDF attachment (link-only — keeps emails light, see
CLAUDE.md note on email_attach_threshold_mb).
**Data:**
- Migration: drop body-hash unique index on `documentEvents`, add
`unique(documensoDocumentId, recipientEmail, eventType)`. Migration
is reversible — the body-hash column stays.
**API:** No new routes. Internal webhook handler only.
**UI:** No change.
**Acceptance:**
- Sequential 3-signer doc: signer 1 signs → signer 2 receives invite
email; signer 2 signs → signer 3 receives invite; signer 3 signs →
COMPLETED fires and CC list gets the signed PDF link.
- Duplicate webhook retries are no-ops (composite key blocks insert).
- Parallel-mode doc: no cascade (all signers got their invite at send).
**Test plan:**
- Integration: mock 3-signer sequential webhook stream, assert email
count + distribution.
- Integration: COMPLETED webhook with CC list, assert link email per CC.
- Unit: idempotency composite key rejects duplicates.
### 1.3 Documenso Phase 5 — Embedded signing URL verification (~12h)
**Goal:** Confirm the marketing site's `/sign/<type>/<token>` route
handles every signer-role × documentType combo; tighten role-specific
copy in invitation emails.
**Scope in:**
- Audit `signerMessages` map in
`src/lib/email/templates/signing-invitation.ts` — fill gaps for
every (role, documentType) pair currently in production.
- nginx CORS block: constrain Documenso webhook receiver origin
(config-only, no code change).
- Manual verification pass: walk through `/sign/eoi/<token>`,
`/sign/contract/<token>`, `/sign/reservation/<token>` for each
signer role (client / approver / developer). Document missing
states in a quick checklist.
**Scope out:**
- New embed surfaces (current routes are sufficient).
- CSP changes (handled in `src/proxy.ts` already).
**Data:** None.
**API:** None.
**UI:** Copy-only changes in invitation email body.
**Acceptance:**
- Each role × doc-type combo renders the correct welcome copy.
- nginx config blocks unknown origins on the webhook receiver
(verified by curl from a non-Documenso IP).
**Test plan:**
- Snapshot tests on email template rendering for each
(role, documentType) tuple.
- Manual walkthrough checklist landed in PR description.
### 1.4 Supplemental form per-port setting (~2h)
**Goal:** "Send supplemental info form" link in auto-emails resolves
to a marketing-site URL when configured per-port; falls back to the
CRM-hosted `/supplemental/[token]` route otherwise.
**Scope in:**
- New `system_settings.supplemental_form_url` key (per-port, optional,
text). Schema already supports arbitrary keys.
- Email link generator in `src/lib/services/sales-emails.ts` (or
wherever the supplemental-info email is composed):
```ts
const url = cfg.supplementalFormUrl
? `${cfg.supplementalFormUrl}?token=${raw}`
: `${env.APP_URL}/supplemental/${raw}`;
```
- Admin UI: add the field to `src/app/(dashboard)/[portSlug]/admin/email/page.tsx`
as a single text input with help hint "Leave blank to use the
built-in CRM page."
- CRM fallback route `/supplemental/[token]/page.tsx`: confirm it still
renders (already exists). Add dual-mode "If you don't see your
details, contact your rep" hint.
**Scope out:** Token format changes; the existing token scheme works
for both modes.
**Data:** New system_settings key only.
**API:** No new routes.
**UI:** One new input on `/admin/email/page.tsx`.
**Acceptance:**
- With URL configured: email link points at marketing site with token
query param.
- With URL blank: email link points at CRM route.
- Token roundtrips through both modes successfully.
**Test plan:**
- Unit: link resolver returns expected URL for both cases.
- Integration: send-out flow with each config variant.
### Phase 1 total effort
~910 hours. Ships as 4 commits in a single PR.
---
## Phase 2 — Deal-pulse signal expansion + admin config UI
**Reference:** `deal-pulse-trigger-audit.md` (call-site inventory).
### Goal
The deal-pulse chip currently shows momentum (stage advancement,
time-in-stage) but ignores high-value pipeline signals. Expand the
signal set + give admins per-port control over which signals fire,
what labels they show, and tier thresholds.
### Signal additions
**Positive (brighten chip):**
- `eoi_sent` — fires when EOI status transitions to `sent`.
Already a call-site in `documents.service.ts` for stage auto-advance;
hook a `pulseSignals.push({ kind: 'eoi_sent', at: now })` next to it.
- `deposit_received` — fires when an invoice with `purpose = 'deposit'`
flips to `status = 'paid'`. Hook in `invoices.service.ts:markPaid`.
- `contract_signed` — fires when a `documents` row with
`templateType = 'contract'` flips to `status = 'completed'`. Hook in
webhook handler `handleDocumentCompleted`.
**Negative (darken chip):**
- `document_declined` — fires when any doc on the interest flips to
`status IN ('declined', 'rejected')`. Hook in
`handleDocumentRejected` (already exists from Phase A).
- `reservation_cancelled` — fires when a `reservations` row flips to
`status = 'cancelled'`. Hook in `reservations.service.ts`.
- `berth_sold_to_other` — fires when the interest's primary berth gets
linked to a different completed interest. Hook in
`interest-berths.service.ts:upsertInterestBerth` when the conflicting
link is detected.
**Cadence tiers:**
- Today: stale flag fires at >7 days in same stage.
- New: tier system reading per-port thresholds:
- `pulse_cadence_warning_days` (default 7) → "Quiet"
- `pulse_cadence_critical_days` (default 21) → "At Risk"
- `pulse_cadence_terminal_days` (default 45) → "Critical"
### Admin config UI
New page `/admin/pulse/page.tsx` (or subsection of `/admin/sales/`):
1. **Master toggle** (`pulse_enabled`, default `true`): off → chip hides
on every interest list/detail surface.
2. **Per-signal toggles** — checkbox per signal, all default on. Stored
as `pulse_signal_<name>_enabled`.
3. **Label rename 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.
4. **Cadence threshold inputs** — three numeric inputs for the day
thresholds above.
5. **Weight tuning** — already partially exists as `heat_weight_*` keys.
Move into this page as a sub-section.
### Schema additions
- New system_settings keys (per-port, all optional, all read with
defaults):
- `pulse_enabled boolean default true`
- `pulse_signal_<name>_enabled boolean default true` for each signal
- `pulse_label_<key> text` for each renamable label
- `pulse_cadence_warning_days int default 7`
- `pulse_cadence_critical_days int default 21`
- `pulse_cadence_terminal_days int default 45`
No new tables. The pulse signal computation is read-time from existing
data (`documents`, `invoices`, `reservations`, `interest_berths`) — no
persistent signal log needed.
### API additions
- `GET /api/v1/admin/pulse/settings` — read current config.
- `PUT /api/v1/admin/pulse/settings` — write config (Zod-validated).
Existing pulse computation in `src/lib/services/deal-pulse.service.ts`:
- Extend `computePulseFor(interestId)` to read per-port settings + new
signal sources.
- Cache settings per-port for the request lifetime.
### UI
- `<DealPulseChip>` already exists; extend label resolution to use
per-port custom labels with fallback to built-ins.
- New admin page (1 file, ~250 LOC).
### Acceptance
- Each signal fires when the linked event happens (verified via
integration test triggering the upstream event).
- Master toggle off → chip absent on every surface.
- Per-signal toggle off → signal absent from chip even when event
fires.
- Custom label "Hot" → "Active" renders correctly.
- Cadence threshold 7 → 14 → 30 → tier transitions match the new
thresholds.
### Test plan
- Unit per signal: trigger upstream event, assert pulse output contains
the signal.
- Unit cadence tier: insert interests with stage-entered timestamps
at boundary ages, assert tier classification.
- Integration: admin page round-trips config save + read.
### Effort
~56h. One PR.
---
## Phase 3 — EOI field overrides
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §1 (base spec) + user
clarifications below.
### Goal
When generating an EOI, rep can override pre-filled fields (contact
info, addresses, yacht name + dimensions) from a dropdown of every
known value for that channel. Manual entries persist as tracked
secondary values; future EOIs can pick them from the dropdown. Yacht
overrides spawn a new yacht record linked to the same interest/client.
### User clarifications captured
1. **Multi-value contacts:** Email and phone fields render as
dropdowns of every `client_contacts` row for that channel.
2. **Per-EOI vs persistent override:**
- "Use only for this EOI" → write to `documents.override_*` cols,
don't touch `client_contacts`.
- "Save as new contact" → insert `client_contacts` row with
`is_primary=false`, `source='eoi-custom-input'`.
- "Set as default for future documents" → promote to
`is_primary=true`, demote the prior primary.
3. **Badge label:** Use `[EOI]` not `[EOI Only]` (future docs may
reuse the value).
4. **Yacht spawn:** EOI form's yacht-name field has a "+ New yacht"
button → inline modal opens with the existing `<YachtForm>`
on save, new yacht linked to same client/interest, tagged with
`yachts.source = 'eoi-generated'`. The current EOI uses the new
yacht. Original yacht untouched.
### Schema additions
- `client_contacts.source text default 'manual'` — values:
`'manual' | 'imported' | 'eoi-custom-input'`.
- `client_contacts.source_document_id text references documents(id) on delete set null`.
- `client_addresses.source` + `source_document_id` (mirror).
- `yachts.source text default 'manual'` — values:
`'manual' | 'imported' | 'eoi-generated'`.
- `yachts.source_document_id text references documents(id) on delete set null`.
- New audit_actions enum entries: `eoi_field_override`,
`promote_to_primary`, `eoi_spawn_yacht`.
- New `documents.override_*` columns (nullable):
`override_client_email`, `override_client_phone`, `override_client_address_line_*`,
etc. — per the field map in `eoi-documenso-field-mapping.md`.
### API additions
- `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary`
promotes a non-primary contact, demotes the prior primary.
- `POST /api/v1/yachts` extension: accepts `source` + `source_document_id`
fields (admin-only or system-only).
- EOI generate endpoint
(`/api/v1/document-templates/[id]/generate-and-sign`) accepts
per-field override params; persists to `documents.override_*` cols
or spawns `client_contacts` rows depending on the toggle state.
### UI surface
- `<EoiGenerateDialog>` — each editable field becomes a `<Combobox>`
with the multi-value list + a "Save as new …" inline action.
- Below each field: two checkboxes:
- `[ ] Use only for this EOI` (default off)
- `[ ] Set as default for future docs` (default off)
- Client + Yacht detail pages: `[EOI]` badge on non-primary
rows; "Set as primary" action on each row.
- Yacht spawn: "+ New yacht" button next to yacht dropdown opens
Sheet (`<Sheet side="right">`, per CLAUDE.md doctrine) with the
existing `<YachtForm>`. On save, new yacht is preselected.
### Acceptance
- Multi-email client: EOI dropdown shows all emails; rep picks the
secondary; EOI uses it.
- "Save as new contact" creates a `client_contacts` row visible in
the client detail panel.
- "Set as default" promotes to primary and demotes the prior.
- Yacht spawn: new yacht visible under both client and interest
with the `[EOI]` badge; original yacht unchanged.
- Audit log records each override action with the source doc id.
### Test plan
- Unit per scenario: per-EOI override, save-as-new, promote-to-primary,
yacht spawn.
- Integration: full EOI generate flow with overrides, assert resulting
doc + side-effects.
- E2E (smoke): rep generates EOI with a custom email + new yacht;
artifacts visible on detail pages.
### Effort breakdown (across sessions)
- **Session 3a (~3 days):** Schema migration + audit_actions + API endpoints
for contact promote + document override persistence. Tests for service layer.
- **Session 3b (~3 days):** UI — EOI form dropdowns, "save as new"
inline flow, "set as default" toggle, badges on client/yacht detail.
- **Session 3c (~2 days):** Yacht spawn flow — Sheet + YachtForm
reuse + interest auto-link. Integration tests + E2E smoke.
- **Session 3d (~12 days):** Polish — audit log surfacing in
audit-log UI, badges/labels in notification copy, documentation.
### Risks
- Schema migration is FK-heavy. Run `pnpm db:generate` carefully;
the partial unique index on `client_contacts.is_primary` (one primary
per channel) must not be broken by the promote endpoint.
- The promote endpoint is a two-step write that must be transactional
(demote prior primary, then promote target) — wrap in
`db.transaction`.
### Effort
~11.5 weeks. Split into 4 sub-sessions per the breakdown above.
---
## Phase 4 — Reminders
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §2.
### Goal
Reps can: (a) attach a follow-up note to interest cadences, (b) create
standalone tasks not tied to an entity, (c) assign tasks to other reps,
(d) configure their default firing time-of-day with per-row override.
### Schema additions
- `interests.reminder_note text NULL` — surfaced in notification body
and inbox row.
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`.
- `reminders.fired_at timestamptz NULL` — drives worker idempotency.
- No new tables. `reminders` table already has title, note, priority,
due_at, assigned_to, snoozed_until, google_calendar_event_id.
### API additions
- `POST /api/v1/reminders` — extend to accept null `linked_entity` for
standalone tasks.
- `PATCH /api/v1/me/profile` — extend to accept `digest_time_of_day`.
- `GET /api/v1/reminders/inbox` — filter `[Mine | All my port]` toggle.
### UI surface
- New shared component `<CreateReminderDialog>` — Title (required),
Note (optional), Due date+time (defaults to user's TOD),
Priority dropdown, Assign-to picker (default = current user),
Linked entity dropdown (only visible from inbox surface; locked
on per-entity surface).
- `<RemindersInbox>`: `[+ New task]` button → dialog.
- Interest / client / berth / yacht detail pages: existing Reminders
section gains `[+ Task]` button → dialog (linked entity pre-filled).
- Settings page: time picker for "default reminder time".
### Worker scheduler
- 15-min cron tick scans `reminders WHERE due_at <= now() AND fired_at IS NULL`,
fires the notification, sets `fired_at`. Wrap in `pg_advisory_xact_lock`
per-port to avoid duplicate fires on parallel workers.
### Acceptance
- Standalone task: created from inbox, no linked entity, fires at
the chosen TOD.
- Per-interest cadence with note: surfaces in notification body +
inbox row.
- Assign to another rep: assignee sees task in their inbox; chip
shows the assignor's name; original creator sees an assignee chip.
- Default TOD set to 14:00 → new reminders default to 14:00; per-row
override to 09:30 wins.
- Worker idempotency: same reminder fires once even if 2 worker
processes race.
### Test plan
- Unit: cron scan picks up due reminders; fired_at gates re-firing.
- Integration: dialog → POST → DB row visible in inbox.
- E2E: rep creates standalone task from inbox; appears for assignee.
### Effort
~34 days. One PR.
---
## Phase 5 — §11.3 Email-copy refactor
**Reference:** old-CRM Nuxt repo at
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
(notable: `server/utils/email.ts`, `server/tasks/process-sales-emails.ts`,
`components/EmailComposer.vue`).
### Goal
Modernize email tone to luxury-port voice; audit per-port branding
chain (logo, signature block, footer copy); ensure every automated
email uses the per-port branded shell.
### Scope in
- **Tone pass:** Rewrite every template in `src/lib/email/templates/`
using the old-CRM templates as voice reference (open them, capture
cadence + phrasing, port to current React-email or HTML-string
format).
- **Branding chain audit:** Walk every sender callsite (`sendEmail` in
`src/lib/email/`), confirm port-specific logo URL and footer get
threaded through via `cfg.portLogoUrl`, `cfg.portFooter`. Fix any
hard-coded `s3.portnimara.com/logo.png` strings (current templates
reference this directly per CLAUDE.md note).
- **New templates if missing:** signing-invitation cascade, supplemental
form, reminder digest. Match the existing tone after the rewrite.
### Scope out
- Localization. Current templates are EN-only; defer i18n to Phase C
unless a port specifically requests another language.
- New triggers. Same set of emails as today, better copy + branding.
### Data
None. Settings keys for branding already exist (`port_logo_url`,
`port_email_footer`).
### API
None.
### UI
None (admin email panel already exposes the settings keys).
### Acceptance
- Each template renders correctly for port-nimara AND a 2nd test port
with different logo + footer.
- Old-CRM reference quotes inline in PR description for traceability.
- No remaining hard-coded port branding strings (grep `portnimara.com`,
expect zero matches outside settings defaults).
### Test plan
- Snapshot tests per template at the rendering layer.
- Manual: send a test email per template to a real inbox, confirm
branding renders.
### Effort
~57 days. The grunt is the tone rewrite (each template needs
~3045 min of focused copywriting + review). One PR with the
template files; CI snapshot tests gate it.
---
## Phase 6 — M-EM03 IMAP bounce-to-interest linking
**Reference:** Phase 7 §14.9 in the original system spec.
### Goal
When an outbound sales email bounces (NDR returned via IMAP), match
the bounce to the originating `document_sends` row and surface a
warning on the linked interest's email tab.
### Scope in
- Bounce parser in `src/lib/email/bounce-parser.ts` — extract original
recipient + bounce reason from common NDR formats (Gmail, Outlook,
Postfix, Exchange).
- Cron job in `src/jobs/processors/imap-bounce-poller.ts` — polls
configured `IMAP_*` mailbox for new bounces, matches against
`document_sends.recipient_email + sent_at`, updates
`document_sends.bounce_status` + `bounce_reason`.
- UI surface on interest's Emails tab: red banner + reason inline
with the bounced send row.
- Notification: rep gets an in-CRM notification when one of their
sends bounces.
### Scope out
- Auto-resending to corrected addresses (manual rep action).
- Out-of-office detection (different signal; defer).
### Data
- `document_sends.bounce_status text NULL` — values: `'hard'`, `'soft'`, `'ooo'`, `null`.
- `document_sends.bounce_reason text NULL`.
- `document_sends.bounce_detected_at timestamptz NULL`.
### API
- No external routes. Internal cron only.
- Existing `GET /api/v1/interests/:id/emails` projection extends to
surface bounce fields.
### UI
- `<InterestEmailsTab>` row gets a red border + "Bounced: <reason>"
banner when `bounce_status IS NOT NULL`.
- Notification bell entry: "Email to X bounced — check the interest".
### Acceptance
- Send 1 real bounced email to a test IMAP mailbox; cron picks it up
within 15 min; UI shows the bounce; notification fires.
- Soft bounce (OOO) vs hard bounce surface differently.
### Test plan
- Unit: parser against fixture NDRs from each provider.
- Integration: cron + DB update path.
- Manual: real bounce round-trip in dev with `EMAIL_REDIRECT_TO` off.
### Effort
~35 days. Parser fixtures are the longest tail.
---
## Phase 7 — PDF template editor (Phases 1+2)
**Reference:** `berth-recommender-and-pdf-plan.md` (background +
infrastructure context).
### Goal
A web-based PDF template editor that lets admins:
- Phase 1: View an existing PDF template, click on a page region to
drop a merge-field marker, save the field map.
- Phase 2: Edit existing fields (move, resize, delete), upload a new
PDF (replacing the source), live-preview the AcroForm fill.
This replaces the current "edit the template PDF in Acrobat, re-upload"
workflow with an in-app editor.
### Phase 7.1 — Read + place (Phase 1, ~2 weeks)
**Scope in:**
- New admin page `/admin/templates/[id]/editor/page.tsx`.
- PDF viewer using `react-pdf` (already in deps for invoice rendering).
- Field marker overlay: click on a region → enter merge-field token
name → marker persists in `document_templates.field_map JSONB`.
- Token autocomplete from `VALID_MERGE_TOKENS`
(`src/lib/templates/merge-fields.ts`).
- Save endpoint: `PUT /api/v1/document-templates/:id/field-map`.
**Scope out:**
- Editing existing AcroForm fields (separate workflow).
- Multi-page navigation (Phase 1 = page 1 only).
- Conditional fields, signatures, repeating sections.
**Data:**
- `document_templates.field_map JSONB 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, ~12 weeks)
**Scope in:**
- Drag-to-move existing markers.
- Drag-corner-to-resize markers.
- Delete marker via right-click → "Remove field".
- Live preview pane (right side) showing the AcroForm fill with
sample data from a chosen interest.
- Multi-page navigation (page picker top-left).
- New-PDF upload: replace the source file while preserving the
field map (warn if coord ranges shift).
**Scope out:**
- Conditional fields / signatures (defer to Phase 3, unscoped).
**Data:** No new schema; reuses `field_map` JSONB.
**API:**
- `POST /api/v1/document-templates/:id/preview` — accepts an
interest ID, returns a presigned URL to a transient preview PDF.
**UI:** Editor extends — page picker, preview pane, drag handlers,
right-click context menu.
**Acceptance:**
- Drag a marker → save → reopen → marker is at the new coords.
- Resize marker → field rendering box matches new dims.
- Upload replacement PDF → field map preserved; warning shown if
page count changed.
- Live preview reflects current field map within 2s of edits.
**Effort:** ~12 weeks.
### Test plan (both phases)
- Unit: field-map serialization + coord persistence.
- Integration: PUT field-map → re-GET → exact roundtrip.
- E2E: admin places marker, generates doc, signed PDF has value at
expected coord (within tolerance).
### Risks
- `react-pdf` performance on large PDFs — measure on 50-page samples
before committing to the page-picker UX.
- Coord system: PDF uses bottom-left origin; viewer uses top-left.
Wrap a single coord-converter to avoid scattered conversions.
### Effort total
~34 weeks for both phases. Highest cost in the plan; queue last.
---
## Execution discipline
Each session that picks up a phase MUST:
1. **Read this doc + the referenced companion** before opening any
source file.
2. **Confirm the issue still exists** — re-grep the call sites listed
in the phase to ensure prior work hasn't already fixed something.
3. **Open a single PR per phase** unless explicitly split into
sub-sessions (Phase 3 is split into 3a/3b/3c/3d).
4. **Run all four quality gates** before reporting done:
`pnpm exec vitest run` · `pnpm tsc --noEmit` · `pnpm lint` ·
`pnpm build` (build only for changes touching middleware, env,
or build config).
5. **Update this doc** — mark the phase ☑ in the sequencing summary
table; capture any spec drift in a `## Implementation notes`
addendum at the end of the phase.
---
## Open questions deferred to phase-start
These don't block the plan but should be resolved when the relevant
phase starts:
- **Phase 1:** Phase 5's nginx config — does the ops repo own this
file, or does this CRM repo? (Resolve before Phase 1.3.)
- **Phase 2:** Should label rename support multi-language, or is
EN-only acceptable for the per-port admin? (Recommend EN-only;
i18n is Phase C work.)
- **Phase 3:** Should `[EOI]` badges fade after a TTL or persist
forever? (Recommend forever — rep chose this label deliberately.)
- **Phase 5:** When the old-CRM tone reference is opened, capture
35 representative templates and quote them in the PR description
for reviewer traceability.
- **Phase 7:** Confirm `react-pdf` performance budget on the largest
template currently in production (capture page-count + LCP).
---
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
## Session log
### Session 2026-05-18 PM — Phases 4 / 2 wiring / 6 / CLAUDE.md
Three of the four "suggested execution order" items shipped; Phase 3b
was deferred (effort estimate exceeded remaining session time).
Recent commits leading into this session:
```
a6e7923 docs(plan): mark Phase 1+2 ☑, Phase 3-7 ◐ partial
df1594d feat(email): Phase 5 — branding chain ext'd with per-port background
9f57868 feat(post-audit): Phase 3/6/7 schema foundations + bounce parser
fb4a09e feat(reminders): Phase 4 partial — schema + service + validators
918c23f feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin
ee3cbb9 docs(plan): expand master plan with detailed implementation appendix
c9debce docs(plan): comprehensive 7-phase master plan for post-audit work
0f99f05 feat(post-audit): batch A+B quick-wins + audit-side residuals
4b5f85c fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
397dbd1 docs(spec): env-to-admin migration design
```
Shipped this session:
- ☑ CLAUDE.md trimmed 27KB → ~19.5KB; added Tools/Skills/MCPs section.
- ☑ Phase 4 polish — yachtId field on `<ReminderForm>` + Ship subtitle on `<ReminderCard>` + `listReminders` filter + `getReminder` yacht relation join.
- ☑ Phase 2 risk-signal data wiring — derivation pass in `getInterestById` (3 parallel queries) populates the 3 risk-signal dates from `document_events` / `berth_reservations` / cross-interest `interest_berths`. Chosen over new schema columns; documented in CLAUDE.md.
- ☑ Phase 6 cron + UI — `imap-bounce-poller.ts` worker wired into maintenance queue at `*/15 * * * *`; matches NDRs to recent `document_sends` rows, fires `email_bounced` notification on hard/soft; admin `/admin/sends` page now shows bounce badge + reason banner.
- Quality gates: 1374/1374 vitest pass, `tsc --noEmit` clean, `pnpm lint` zero errors (37 pre-existing warnings).
Deferred:
- Phase 3b — EOI dialog override UI (combobox per field + 2 checkboxes) was the 4th item; master-plan estimate is 2-3 days and exceeded remaining session time.
- Phase 4 worker scheduler refactor (fired_at gate cron tick).
- Phase 6 interest-detail "Emails" tab — the tab surface doesn't exist yet; bounce banner will live there when the tab lands.
---
## Phase ☑/☐ tracker
- ☑ Phase 1 — Documenso completion + Supplemental form (commits df1594d, 918c23f)
- ☑ 1.1 Documenso Phase 7 (RBAC) — already in code prior; verified at documents.service.ts:1268-1300
- ☑ 1.2 Documenso Phase 2 (Webhook UX cascading invite) — already in code prior; verified
- ☑ 1.3 Documenso Phase 5 (Embedded signing) — copy made order-agnostic + developer-role branch
- ☑ 1.4 Supplemental form per-port URL — registry + getPortEmailConfig + route
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
- ☑ 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
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
- ☑ Data-wiring: derivation pass inside `getInterestById` — runs 3 parallel queries
against `document_events` (rejected/declined), `berth_reservations`
(status='cancelled'), and other `won` interests sharing a berth via `interest_berths`.
Returns the 3 dates on the API response; `interest-detail-header` threads them
through to `<DealPulseChip>`. Chosen over new schema columns to keep the master
plan's "no new tables" promise. Documented in CLAUDE.md.
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
- ◐ Phase 3 — EOI field overrides (9f57868 + session 2026-05-18 PM)
- ☑ 3a — Schema migration 0073, Drizzle additions, audit_actions free-text verbs
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
- ☑ 3b — EOI dialog UI overrides for email/phone/yacht-name; service-level
side-effects (create non-primary contact, promote-to-primary, write
documents.override\_\*) inside a single transaction via
`src/lib/services/eoi-overrides.service.ts`. Both pathways (inapp +
Documenso template) layer overrides onto the in-memory EoiContext
before render. Audit verbs `eoi_field_override` + `promote_to_primary`
- `eoi_spawn_yacht` formalised in `src/lib/audit.ts`. Address
overrides + per-yacht detail badge deferred.
- ☑ 3c — "+ New yacht" button next to yacht-name field opens nested
`<YachtForm>` Sheet (pre-fills owner = current client, stamps
`source='eoi-generated'`); on save, the interest's yachtId is patched
so the EOI's yacht block populates without a manual re-link.
- ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary`
(transactional demote+promote via `promoteContactToPrimary`); `[EOI]`
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
badge on non-primary contact rows in `<ContactsEditor>` + on yacht
detail header when `yacht.source === 'eoi-generated'`.
- ☐ Address override field in EOI dialog (schema columns exist)
- ☐ Audit-log UI surfacing of new verbs (rows written, filter chips missing)
- ☐ Backfill yachts.source_document_id after EOI document is created
(currently set NULL because the yacht is spawned BEFORE the doc row exists)
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
- ◐ 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
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
- ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope)
-`<ReminderCard>` shows yacht subtitle (Ship icon + yacht name)
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
-`listReminders` filters by query.yachtId; `getReminder` joins yacht relation
- ☑ Worker `processOverdueReminders` claims due rows via `UPDATE...RETURNING`
with `fired_at IS NULL` race-safe gate, so parallel workers can't
double-fire the same reminder.
-`user_profiles.preferences.digestTimeOfDay` picker on `/settings`
(time input + help text). `<ReminderForm>` honours the preference via
a React-Query me-prefs fetch keyed `['me', 'preferences']`.
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
- ☑ 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)
feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work Phase 3 — EOI overrides (now ☑): - Address override field with the same per-component input UX as the canonical address form (line1/line2/city/state/postal + ISO subdivision + CountryCombobox). Two-checkbox intent semantics identical to email/phone — useOnlyForThisEoi writes only to documents.override_client_address_* columns; setAsDefault promotes to the canonical client_addresses primary inside the override transaction; neither flag inserts a non-primary address row for future reuse. eoi-context route now returns available.addresses so the dialog can render the picker over existing rows. - yachts.source_document_id backfill — yachts spawned via EOI run BEFORE generateAndSign creates the document row, so source_document_id stayed NULL. Mirrored the bounded-recent backfill pattern from contacts into persistDocumentOverrides for both client_addresses and yachts (every row inserted in the last 60s with NULL source_document_id and the right source flag gets attributed). - Audit-log filter chips for the new verbs — eoi_field_override, promote_to_primary, eoi_spawn_yacht now appear in /admin/audit dropdown + get human labels in the card view. Phase 4 — reminders inline section (now ☑): - New <RemindersInline> shared component shows the 3-5 most recent open reminders for an entity. Mounted on Overview tab of yacht / client / interest detail. Empty state hints at the header button rather than duplicating it. Phase 5 — email tone (now ☑ across all 8 templates): - admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry — voice + sign-off match the 4 shipped earlier ("Dear X", "With warm regards, The {portName} Team", sentence-case subjects). Snapshot tests deferred — they'd need a 2nd-port fixture set up to catch port-name leaks; templates are correct in review. Phase 7 — PDF editor (now ☑): - 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes" badge), ResizeObserver-driven responsive PDF width, required-tokens- unplaced indicator reading template.mergeFields. - 7.2 drag-to-move with on-page clamping. - 7.2 four-corner resize handles with min-size enforcement. - 7.2 right-click context delete via onContextMenu. - 7.2 multi-page navigation + per-page marker filter. - 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview runs the in-app pdf-lib fill against the supplied interest, uploads to a transient previews/ key, returns a 15-min presigned URL. - 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf takes multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps documentTemplates.sourceFileId. Editor warns when the new page count truncates the prior set. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:09:19 +02:00
- ☑ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM x3)
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work Phase 3 — EOI overrides (now ☑): - Address override field with the same per-component input UX as the canonical address form (line1/line2/city/state/postal + ISO subdivision + CountryCombobox). Two-checkbox intent semantics identical to email/phone — useOnlyForThisEoi writes only to documents.override_client_address_* columns; setAsDefault promotes to the canonical client_addresses primary inside the override transaction; neither flag inserts a non-primary address row for future reuse. eoi-context route now returns available.addresses so the dialog can render the picker over existing rows. - yachts.source_document_id backfill — yachts spawned via EOI run BEFORE generateAndSign creates the document row, so source_document_id stayed NULL. Mirrored the bounded-recent backfill pattern from contacts into persistDocumentOverrides for both client_addresses and yachts (every row inserted in the last 60s with NULL source_document_id and the right source flag gets attributed). - Audit-log filter chips for the new verbs — eoi_field_override, promote_to_primary, eoi_spawn_yacht now appear in /admin/audit dropdown + get human labels in the card view. Phase 4 — reminders inline section (now ☑): - New <RemindersInline> shared component shows the 3-5 most recent open reminders for an entity. Mounted on Overview tab of yacht / client / interest detail. Empty state hints at the header button rather than duplicating it. Phase 5 — email tone (now ☑ across all 8 templates): - admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry — voice + sign-off match the 4 shipped earlier ("Dear X", "With warm regards, The {portName} Team", sentence-case subjects). Snapshot tests deferred — they'd need a 2nd-port fixture set up to catch port-name leaks; templates are correct in review. Phase 7 — PDF editor (now ☑): - 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes" badge), ResizeObserver-driven responsive PDF width, required-tokens- unplaced indicator reading template.mergeFields. - 7.2 drag-to-move with on-page clamping. - 7.2 four-corner resize handles with min-size enforcement. - 7.2 right-click context delete via onContextMenu. - 7.2 multi-page navigation + per-page marker filter. - 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview runs the in-app pdf-lib fill against the supplied interest, uploads to a transient previews/ key, returns a 15-min presigned URL. - 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf takes multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps documentTemplates.sourceFileId. Editor warns when the new page count truncates the prior set. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:09:19 +02:00
- ☑ 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)
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
- ◐ 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)
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
- ☑ Cron worker `src/jobs/processors/imap-bounce-poller.ts` — reads IMAP\__ env,
matches NDR recipient to recent document_sends, idempotent via `bounceDetectedAt`,
fires `email_bounced` notification on hard/soft (skips OOO); state persisted to
`system_settings.bounce_poller_state` (port_id=NULL). Wired into maintenance
queue at `_/15 \* \* \* \*`.
- ☑ UI banner on `/admin/sends` (admin sends-log) + `email_bounced` notification type
- ☐ Interest-detail "Emails" tab — surface tab doesn't exist yet; bounce banner
would live there when the tab lands (deferred to a wider emails-surface session)
- ☐ Manual round-trip test against real bounced delivery
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
- **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.
feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work Phase 3 — EOI overrides (now ☑): - Address override field with the same per-component input UX as the canonical address form (line1/line2/city/state/postal + ISO subdivision + CountryCombobox). Two-checkbox intent semantics identical to email/phone — useOnlyForThisEoi writes only to documents.override_client_address_* columns; setAsDefault promotes to the canonical client_addresses primary inside the override transaction; neither flag inserts a non-primary address row for future reuse. eoi-context route now returns available.addresses so the dialog can render the picker over existing rows. - yachts.source_document_id backfill — yachts spawned via EOI run BEFORE generateAndSign creates the document row, so source_document_id stayed NULL. Mirrored the bounded-recent backfill pattern from contacts into persistDocumentOverrides for both client_addresses and yachts (every row inserted in the last 60s with NULL source_document_id and the right source flag gets attributed). - Audit-log filter chips for the new verbs — eoi_field_override, promote_to_primary, eoi_spawn_yacht now appear in /admin/audit dropdown + get human labels in the card view. Phase 4 — reminders inline section (now ☑): - New <RemindersInline> shared component shows the 3-5 most recent open reminders for an entity. Mounted on Overview tab of yacht / client / interest detail. Empty state hints at the header button rather than duplicating it. Phase 5 — email tone (now ☑ across all 8 templates): - admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry — voice + sign-off match the 4 shipped earlier ("Dear X", "With warm regards, The {portName} Team", sentence-case subjects). Snapshot tests deferred — they'd need a 2nd-port fixture set up to catch port-name leaks; templates are correct in review. Phase 7 — PDF editor (now ☑): - 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes" badge), ResizeObserver-driven responsive PDF width, required-tokens- unplaced indicator reading template.mergeFields. - 7.2 drag-to-move with on-page clamping. - 7.2 four-corner resize handles with min-size enforcement. - 7.2 right-click context delete via onContextMenu. - 7.2 multi-page navigation + per-page marker filter. - 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview runs the in-app pdf-lib fill against the supplied interest, uploads to a transient previews/ key, returns a 15-min presigned URL. - 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf takes multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps documentTemplates.sourceFileId. Editor warns when the new page count truncates the prior set. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:09:19 +02:00
- ☑ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM x3)
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator
feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side <TemplateEditor>. - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:37:19 +02:00
- ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side
`<TemplateEditor>` with react-pdf, click-to-place markers, token picker
feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work Phase 3 — EOI overrides (now ☑): - Address override field with the same per-component input UX as the canonical address form (line1/line2/city/state/postal + ISO subdivision + CountryCombobox). Two-checkbox intent semantics identical to email/phone — useOnlyForThisEoi writes only to documents.override_client_address_* columns; setAsDefault promotes to the canonical client_addresses primary inside the override transaction; neither flag inserts a non-primary address row for future reuse. eoi-context route now returns available.addresses so the dialog can render the picker over existing rows. - yachts.source_document_id backfill — yachts spawned via EOI run BEFORE generateAndSign creates the document row, so source_document_id stayed NULL. Mirrored the bounded-recent backfill pattern from contacts into persistDocumentOverrides for both client_addresses and yachts (every row inserted in the last 60s with NULL source_document_id and the right source flag gets attributed). - Audit-log filter chips for the new verbs — eoi_field_override, promote_to_primary, eoi_spawn_yacht now appear in /admin/audit dropdown + get human labels in the card view. Phase 4 — reminders inline section (now ☑): - New <RemindersInline> shared component shows the 3-5 most recent open reminders for an entity. Mounted on Overview tab of yacht / client / interest detail. Empty state hints at the header button rather than duplicating it. Phase 5 — email tone (now ☑ across all 8 templates): - admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry — voice + sign-off match the 4 shipped earlier ("Dear X", "With warm regards, The {portName} Team", sentence-case subjects). Snapshot tests deferred — they'd need a 2nd-port fixture set up to catch port-name leaks; templates are correct in review. Phase 7 — PDF editor (now ☑): - 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes" badge), ResizeObserver-driven responsive PDF width, required-tokens- unplaced indicator reading template.mergeFields. - 7.2 drag-to-move with on-page clamping. - 7.2 four-corner resize handles with min-size enforcement. - 7.2 right-click context delete via onContextMenu. - 7.2 multi-page navigation + per-page marker filter. - 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview runs the in-app pdf-lib fill against the supplied interest, uploads to a transient previews/ key, returns a 15-min presigned URL. - 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf takes multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps documentTemplates.sourceFileId. Editor warns when the new page count truncates the prior set. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:09:19 +02:00
from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions.
- ☑ 7.1 polish — unsaved-changes guard (beforeunload + visual "Unsaved
changes" badge), responsive PDF width via ResizeObserver, required
tokens unplaced indicator that reads `template.mergeFields`.
- ☑ 7.2 drag-to-move with on-page clamping; 4 corner resize handles
(NW/NE/SW/SE) with min-size + on-page clamping.
- ☑ 7.2 multi-page navigation (page picker + per-page marker filter).
- ☑ 7.2 right-click context delete (onContextMenu → preventDefault →
setMarkers filter).
- ☑ 7.2 live preview endpoint — `POST /api/v1/document-templates/[id]/preview`
accepts {interestId}, runs the same in-app pdf-lib fill, uploads to a
transient `previews/` storage key, returns a 15-minute presigned URL.
- ☑ 7.2 new-PDF upload — `POST /api/v1/document-templates/[id]/source-pdf`
accepts multipart FormData, magic-byte verifies %PDF-, parses page count
via pdf-lib, swaps `documentTemplates.sourceFileId` to the new files row.
Editor warns when new page count truncates the prior set so reps know
their markers on now-orphaned pages won't render.
---
# Detailed Implementation Appendix
This appendix expands every phase with per-file change lists, schema
migration SQL skeletons, API request/response shapes, and component
breakdowns. Anything ambiguous in the phase summaries above is resolved
here. Read this in conjunction with the phase header.
---
## Appendix A — Phase 1 (Documenso completion + Supplemental form)
### A.1 — Status of each sub-phase against existing code
A grep + read pass at the time of writing this appendix confirmed:
- **1.1 Project Director RBAC notification → already in code**
(`src/lib/services/documents.service.ts:1268-1300`). Registry keys
`documenso_developer_user_id` + `documenso_approver_user_id` exist
(`src/lib/settings/registry.ts:116, 162`). Admin UI renders them via
`<RegistryDrivenForm sections={['documenso.signers']}>` with the
`user-select` field type (`registry-driven-form.tsx:499-507`).
**Verification only.** Smoke test by linking a CRM user on a port,
triggering a recipient-signed webhook for the matching role, and
asserting the linked user receives a `document_signing_your_turn`
notification in their inbox.
- **1.2 Cascading invite to next signer → already in code**
(`sendCascadingInviteForNextSigner` at `documents.service.ts:1220`).
**Verification only.** Send a 3-signer sequential EOI, sign
recipient 1, assert recipient 2 receives a branded "your turn"
email within 30s.
- **1.3 Embedded signing copy + nginx CORS → partial.** Signing
invitation copy lives in `src/lib/email/templates/` — needs a
grep for the actual file path. nginx config: confirm if owned by
this repo or the ops repo. → **Implementation needed.**
- **1.4 Supplemental form per-port URL → not started.** Existing
service at `src/lib/services/supplemental-forms.service.ts` mints
tokens for the CRM-hosted `/supplemental/[token]` route. → **Full
implementation needed.**
### A.2 — Supplemental form per-port: per-file change list
1. **`src/lib/settings/registry.ts`** — Add a new entry:
```ts
{
key: 'supplemental_form_url',
section: 'email.general', // or new 'supplemental' section
label: 'Supplemental form URL (optional)',
description:
'When set, supplemental-info emails link to this URL with ?token=… appended. Leave blank to use the built-in CRM form at /supplemental/<token>.',
type: 'string',
scope: 'port',
placeholder: 'https://portnimara.com/supplemental',
},
```
2. **`src/lib/services/port-config.ts`** — Map the new key:
```ts
supplementalFormUrl: 'supplemental_form_url',
```
3. **Email send-out call site** — Find via:
`grep -rn "supplemental" src/lib/email src/lib/services/sales-emails*`
The link assembly looks like:
```ts
const cfg = await getPortEmailConfig(portId);
const url = cfg.supplementalFormUrl
? `${cfg.supplementalFormUrl}?token=${encodeURIComponent(raw)}`
: `${env.APP_URL}/supplemental/${raw}`;
```
4. **Admin page** — Re-render via `<RegistryDrivenForm sections={['email.general']} />`
(or new section). No JSX edit needed if the section key matches an
existing card.
5. **Fallback route confirmation**`src/app/(portal)/public/supplemental-info`
stays as-is. Adds copy "If you don't see your details, contact your rep."
### A.3 — Test plan additions
- **Vitest unit:** `supplemental-form-link.test.ts`
`resolveSupplementalUrl(cfg, raw)` returns external URL when set,
CRM URL when blank.
- **Vitest integration:** `supplemental-email-send.test.ts` — mocks
a port with `supplemental_form_url` set; assert sent email body
contains the external URL.
- **Playwright (smoke):** admin can set + clear the URL; UI persists.
### A.4 — Phase 1 effort revision
Given 1.1 + 1.2 are already shipped, real remaining work is ~34h:
- 1.3 signing-invitation copy audit: ~1h
- 1.3 nginx CORS: 5min if it's already documented, ~30min if not
- 1.4 supplemental form: ~2h
- Tests + smoke: ~30min
---
## Appendix B — Phase 2 (Deal-pulse signals + admin config UI)
### B.1 — Schema migration SQL
```sql
-- 0072_pulse_admin_config.sql
-- All keys are stored in `system_settings` as JSON values with the
-- standard per-port scoping. No new columns or tables needed; the
-- registry-driven form handles serialization.
-- No DDL — registry entries below seed the keys lazily on first read.
```
### B.2 — Registry entries to add
In `src/lib/settings/registry.ts`:
```ts
// ─── Deal Pulse ────────────────────────────────────────────────────
{ key: 'pulse_enabled', section: 'pulse', label: 'Show deal pulse chips',
description: 'Master toggle. Off hides every pulse chip on every surface.',
type: 'boolean', scope: 'port', defaultValue: 'true' },
{ key: 'pulse_signal_eoi_sent_enabled', section: 'pulse',
label: 'Signal: EOI sent', type: 'boolean', scope: 'port', defaultValue: 'true' },
{ key: 'pulse_signal_deposit_received_enabled', /* ... */ },
{ key: 'pulse_signal_contract_signed_enabled', /* ... */ },
{ key: 'pulse_signal_document_declined_enabled', /* ... */ },
{ key: 'pulse_signal_reservation_cancelled_enabled', /* ... */ },
{ key: 'pulse_signal_berth_sold_to_other_enabled', /* ... */ },
{ key: 'pulse_label_hot', section: 'pulse',
label: '"Hot" label override (default: Hot)',
description: 'Empty = use built-in label.',
type: 'string', scope: 'port' },
{ key: 'pulse_label_quiet', /* default: "Quiet" */ },
{ key: 'pulse_label_at_risk', /* default: "At Risk" */ },
{ key: 'pulse_label_critical', /* default: "Critical" */ },
{ key: 'pulse_label_eoi_sent', /* default: "EOI sent" */ },
{ key: 'pulse_label_deposit_received', /* default: "Deposit paid" */ },
{ key: 'pulse_label_contract_signed', /* default: "Contract signed" */ },
{ key: 'pulse_label_document_declined', /* default: "Declined" */ },
{ key: 'pulse_label_reservation_cancelled', /* default: "Reservation cancelled" */ },
{ key: 'pulse_label_berth_sold_to_other', /* default: "Berth resold" */ },
{ key: 'pulse_cadence_warning_days', section: 'pulse',
label: 'Warning threshold (days)', type: 'number', scope: 'port',
defaultValue: '7' },
{ key: 'pulse_cadence_critical_days', /* default 21 */ },
{ key: 'pulse_cadence_terminal_days', /* default 45 */ },
```
### B.3 — Signal-firing hook sites
| Signal | Hook file | Hook function |
| ----------------------- | --------------------------------------------- | ------------------------------------------------------------ |
| `eoi_sent` | `src/lib/services/documents.service.ts` | `sendDocument` / `markAsSent` |
| `deposit_received` | `src/lib/services/invoices.service.ts` | `markPaid` (filter `purpose='deposit'`) |
| `contract_signed` | `src/lib/services/documents.service.ts` | `handleDocumentCompleted` (filter `templateType='contract'`) |
| `document_declined` | `src/lib/services/documents.service.ts` | `handleDocumentRejected` |
| `reservation_cancelled` | `src/lib/services/reservations.service.ts` | `cancelReservation` |
| `berth_sold_to_other` | `src/lib/services/interest-berths.service.ts` | `upsertInterestBerth` when conflict detected |
Each hook fires the signal by emitting a row into a new lightweight
table OR by recording a timestamp on the interest. Recommend the
timestamp pattern (no new table):
```sql
ALTER TABLE interests
ADD COLUMN pulse_last_eoi_sent_at timestamptz,
ADD COLUMN pulse_last_deposit_received_at timestamptz,
ADD COLUMN pulse_last_contract_signed_at timestamptz,
ADD COLUMN pulse_last_document_declined_at timestamptz,
ADD COLUMN pulse_last_reservation_cancelled_at timestamptz,
ADD COLUMN pulse_last_berth_sold_to_other_at timestamptz;
```
The pulse compute function then reads these columns + the per-port
admin config to assemble the chip output.
### B.4 — Pulse compute function refactor
`src/lib/services/deal-pulse.service.ts:computePulseFor(interestId)`:
```ts
export interface PulseResult {
visible: boolean; // false if master toggle off
tier: 'neutral' | 'hot' | 'quiet' | 'at_risk' | 'critical';
tierLabel: string; // resolved from per-port label override or default
signals: Array<{
kind: 'eoi_sent' | 'deposit_received' | /* ... */;
label: string; // resolved
at: Date;
}>;
}
```
The function:
1. Reads `pulse_enabled` → returns `{ visible: false }` early if off.
2. Reads per-signal toggles + label overrides into a memoized config.
3. Reads cadence-tier thresholds.
4. Computes tier from `stage_entered_at` against thresholds.
5. Builds the signals array — most-recent first, filtered by toggle
state.
### B.5 — Admin page
New file `src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx`:
```tsx
export default function PulseSettingsPage() {
return (
<div className="space-y-6">
<PageHeader title="Deal Pulse" description="…" />
<RegistryDrivenForm
sections={['pulse']}
title="Pulse chip behaviour"
description="Toggle the chip, rename labels per port, tune cadence thresholds."
/>
</div>
);
}
```
Add a link entry in `src/components/admin/admin-sections-browser.tsx`.
### B.6 — UI usage
`<DealPulseChip>` already exists. Extend it to:
1. Accept the full `PulseResult` (not just the tier).
2. Hide entirely when `visible: false`.
3. Render signal chips on hover/expand with their resolved labels.
### B.7 — Test plan
- **Unit per signal firing:** Insert an interest, trigger the upstream
event, assert the `pulse_last_<signal>_at` column updated.
- **Unit per signal toggling:** With master toggle off → `computePulseFor`
returns `{ visible: false }`. With per-signal toggle off → signal
absent from `signals[]`.
- **Unit per cadence:** Interest with `stage_entered_at` at boundaries
(6d, 7d, 21d, 22d, 45d, 46d) — tier transitions match.
- **Integration:** Admin page round-trips config save + read; chip
reflects changes within the request lifetime cache window.
---
## Appendix C — Phase 3 (EOI field overrides) — comprehensive
### C.1 — Decision rationale (locked from user input)
1. Contact-channel dropdowns show every `client_contacts` row for that
channel, defaulting to the row with `is_primary=true`.
2. Override behaviours, controlled by two checkboxes below each field:
- Neither ticked → write to `documents.override_<field>` only.
- "Use only for this EOI" ticked → same as above (explicit).
- "Save as new contact" → insert `client_contacts` row,
`is_primary=false`, `source='eoi-custom-input'`.
- "Set as default for future docs" → above + promote new row to
`is_primary=true`, demote prior primary inside one transaction.
3. Badge label: `[EOI]` (not `[EOI Only]`).
4. Yacht overrides: spawn new yacht via inline Sheet + `<YachtForm>`.
New yacht tagged `yachts.source='eoi-generated'` and
`yachts.source_document_id=<doc-id>`. Original yacht untouched.
5. Audit trail: every action emits `audit_log` row with action
`eoi_field_override`, `promote_to_primary`, or `eoi_spawn_yacht`.
### C.2 — Schema migration SQL
```sql
-- 0073_eoi_overrides.sql
-- Track origin of contacts so non-primary rows surface as "[EOI]"
-- and so we can reverse-link them to the generating document.
ALTER TABLE client_contacts
ADD COLUMN source text NOT NULL DEFAULT 'manual',
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
ADD CONSTRAINT chk_client_contacts_source
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
-- Same pattern for addresses.
ALTER TABLE client_addresses
ADD COLUMN source text NOT NULL DEFAULT 'manual',
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
ADD CONSTRAINT chk_client_addresses_source
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
-- Yacht origin tracking.
ALTER TABLE yachts
ADD COLUMN source text NOT NULL DEFAULT 'manual',
ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL,
ADD CONSTRAINT chk_yachts_source
CHECK (source IN ('manual', 'imported', 'eoi-generated'));
-- Per-document overrides — stored on the document itself, separate
-- from the canonical client/yacht records. The full field set mirrors
-- VALID_MERGE_TOKENS from src/lib/templates/merge-fields.ts.
ALTER TABLE documents
ADD COLUMN override_client_email text,
ADD COLUMN override_client_phone text,
ADD COLUMN override_client_address_line_1 text,
ADD COLUMN override_client_address_line_2 text,
ADD COLUMN override_client_city text,
ADD COLUMN override_client_state text,
ADD COLUMN override_client_postal_code text,
ADD COLUMN override_client_country text,
ADD COLUMN override_yacht_name text,
ADD COLUMN override_yacht_length_ft numeric(10,2),
ADD COLUMN override_yacht_width_ft numeric(10,2),
ADD COLUMN override_yacht_draft_ft numeric(10,2);
-- Audit-actions enum gains 3 new verbs. Drizzle treats these as
-- string union — update the enum definition + run the seed audit.
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_field_override';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'promote_to_primary';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_spawn_yacht';
```
### C.3 — Drizzle schema updates
`src/lib/db/schema/clients.ts`:
```ts
export const clientContacts = pgTable('client_contacts', {
// existing columns...
source: text('source').notNull().default('manual'),
sourceDocumentId: text('source_document_id').references(() => documents.id, {
onDelete: 'set null',
}),
});
```
Mirror in `client_addresses` and `yachts.ts`. Add the override columns
to `documents.ts`. Drop in `EoiOverrideValuesSchema` zod type at
`src/lib/validators/documents.ts`.
### C.4 — API endpoints
#### C.4.1 Promote contact to primary
`POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary`
Request: empty body.
Response: `{ data: { promoted: <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:
```ts
export const generateAndSignSchema = z.object({
interestId: z.string(),
pathway: z.enum(['documenso', 'in-app']),
overrides: z
.object({
values: z.record(z.string(), z.string()).optional(),
// For each overridden field, the rep can pick:
// - 'document-only': write to documents.override_<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:
```ts
{
// existing required: name, ownerType, ownerId, ...
source: 'eoi-generated' | 'manual',
sourceDocumentId?: string | null, // populated when source === 'eoi-generated'
interestId?: string, // when set, auto-link as interest's yacht
}
```
### C.5 — UI surface — per file
#### C.5.1 `<EoiGenerateDialog>` (or rename to Sheet per CLAUDE.md)
File: `src/components/documents/eoi-generate-dialog.tsx` (existing).
Per field (email, phone, address, yacht):
1. Replace `<Input>` with `<Combobox>` populated from multi-value rows.
2. Below: 2 checkboxes:
- `[ ] Use only for this EOI`
- `[ ] Set as default for future docs`
3. For yacht field: append "+ New yacht" button next to the dropdown
that opens an inline Sheet (`<Sheet side="right">`) wrapping the
existing `<YachtForm>`. On save, new yacht is preselected.
#### C.5.2 Client detail panel — contacts list
File: `src/components/clients/client-form.tsx` (or wherever contacts list).
- Add `[EOI]` chip on rows where `source === 'eoi-custom-input'`.
- Add "Set as primary" inline action on non-primary rows; calls
C.4.1.
#### C.5.3 Yacht detail panel
File: `src/components/yachts/yacht-form.tsx` (or detail page).
- Show `[EOI]` chip when `yacht.source === 'eoi-generated'`.
- Link "Generated from EOI: <doc title>" 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
```sql
-- 0074_reminders_expansion.sql
ALTER TABLE interests
ADD COLUMN reminder_note text;
ALTER TABLE user_profiles
ADD COLUMN digest_time_of_day time NOT NULL DEFAULT '09:00';
ALTER TABLE reminders
ADD COLUMN fired_at timestamptz;
-- Worker idempotency: ensure two parallel workers can't double-fire.
CREATE UNIQUE INDEX uniq_reminders_fired_once
ON reminders (id)
WHERE fired_at IS NOT NULL;
-- (logically unique by PK anyway, but the index serves as a self-
-- documenting fingerprint for the worker's "did I already fire?" check.)
```
### D.2 — Service additions
`src/lib/services/reminders.service.ts` (existing — extend):
```ts
export async function createReminder(input: {
portId: string;
userId: string;
assigneeId?: string; // defaults to userId
title: string;
note?: string;
priority?: 'low' | 'medium' | 'high';
dueAt: Date;
linkedEntityType?: 'interest' | 'client' | 'berth' | 'yacht' | null;
linkedEntityId?: string | null;
}): Promise<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`:
```ts
// Runs every 15 minutes via the BullMQ scheduler.
export async function fireReadyReminders(): Promise<void> {
// Per-port advisory lock to prevent two workers double-firing.
for (const port of await listPortIds()) {
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(${hashPortToBigint(port)})`);
const due = await tx
.select()
.from(reminders)
.where(
and(
eq(reminders.portId, port),
lte(reminders.dueAt, new Date()),
isNull(reminders.firedAt),
),
);
for (const r of due) {
await fireOne(r, tx);
await tx.update(reminders).set({ firedAt: new Date() }).where(eq(reminders.id, r.id));
}
});
}
}
```
### D.4 — UI: shared dialog component
New file `src/components/reminders/create-reminder-dialog.tsx`:
Fields: Title (required), Note (optional, textarea), Due date+time
(defaults to today + user's `digest_time_of_day`), Priority dropdown,
Assignee combobox (port users via `/api/v1/admin/users/picker`),
Linked entity dropdown (hidden when pre-filled).
### D.5 — Mount points
1. `src/components/reminders/reminders-inbox.tsx`: `[+ New task]`
button in toolbar.
2. `src/components/interests/interest-detail-header.tsx`: `[+ Task]`
button next to existing Reminders panel.
3. Mirror for clients/berths/yachts detail pages.
### D.6 — Settings page
`src/app/(dashboard)/[portSlug]/settings/notifications/page.tsx`
(or wherever user-level prefs live): add a time picker bound to
`user_profiles.digest_time_of_day` via PATCH `/api/v1/me/profile`.
### D.7 — Sub-session breakdown
- **4a — Schema + service + worker (1.5 days)**
- **4b — Dialog component + 4 mount points (1.5 days)**
- **4c — Settings page time-of-day picker + tests (0.5 days)**
- **4d — Integration + E2E (0.5 days)**
---
## Appendix E — Phase 5 (Email-copy refactor) — comprehensive
### E.1 — Old-CRM reference location (captured)
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
Notable files:
- `server/utils/email.ts` — Nodemailer wrapper with subject/html shape.
- `server/tasks/process-sales-emails.ts` — automated send-out cadence.
- `components/EmailComposer.vue` — UI tone reference.
- `components/EmailCommunication.vue` — body markdown handling.
**Step 1 of execution:** open these three files, capture 35 representative
template strings, quote in PR description for reviewer traceability.
### E.2 — Templates to refactor (per-file)
Current `src/lib/email/templates/`:
- `portal-auth.ts` — activation + reset (already branded, voice pass needed)
- `signing-invitation.ts` — voice-pass + role-specific copy completeness check
- `signing-completion.ts` — voice-pass
- `supplemental-info-request.ts` — voice-pass + link to per-port URL
(depends on Phase 1.4)
- `reminder-digest.ts` — voice-pass; ties into Phase 4 (reminders)
- `bounce-warning.ts` — voice-pass; depends on Phase 6 (bounce linking)
- `port-invitation.ts` (CRM invite) — voice-pass
- `change-email-confirmation.ts` — voice-pass
### E.3 — Branding chain audit
Grep `s3.portnimara.com` across `src/lib/email/templates/` — replace
hard-coded URLs with `cfg.portLogoUrl` / `cfg.portEmailFooter`.
Confirm every `sendEmail` callsite threads `portId` through to
`getPortEmailConfig(portId)` (not the env-fallback shape).
### E.4 — Tone guidance
After reading the old-CRM templates, write a 1-page tone guide at
`docs/email-tone-guide.md` capturing:
- Sentence cadence (concise, second-person, no marketing fluff).
- Salutation conventions ("Dear <Name>" vs "Hello <First>").
- Sign-off conventions (rep name + role + port name).
- Action-phrase tone ("you may sign here" vs "click to sign").
Reviewer uses the guide to verify each refactored template.
### E.5 — Test plan
- **Snapshot per template:** `pnpm exec vitest run src/lib/email/templates/**.test.ts`
asserts each template renders for port-nimara and a 2nd test port
with different logo + footer.
- **Manual test send:** seed 8 representative scenarios; send each
to a test inbox (real or `EMAIL_REDIRECT_TO`); manually verify the
output reads in tone.
### E.6 — Sub-session breakdown
- **5a — Reference capture (0.5 days):** Open old-CRM, capture tone
guide, write `docs/email-tone-guide.md`.
- **5b — Branding chain audit (0.5 days):** Grep hard-coded URLs;
fix every call to thread port-specific values.
- **5c — Tone pass batch 1 (1.5 days):** portal-auth, signing-\*,
port-invitation, change-email-confirmation.
- **5d — Tone pass batch 2 (1.5 days):** supplemental-info,
reminder-digest, bounce-warning (waits on Phase 4 + 6 if needed).
- **5e — Snapshot tests + manual sends (1 day).**
---
## Appendix F — Phase 6 (IMAP bounce-to-interest linking) — comprehensive
### F.1 — Schema migration SQL
```sql
-- 0075_bounce_tracking.sql
ALTER TABLE document_sends
ADD COLUMN bounce_status text,
ADD COLUMN bounce_reason text,
ADD COLUMN bounce_detected_at timestamptz,
ADD CONSTRAINT chk_document_sends_bounce_status
CHECK (bounce_status IS NULL OR bounce_status IN ('hard', 'soft', 'ooo'));
CREATE INDEX idx_document_sends_bounce_status
ON document_sends (port_id, bounce_status)
WHERE bounce_status IS NOT NULL;
```
### F.2 — Parser fixtures (the long tail)
`tests/fixtures/bounces/`:
- `gmail-hard.eml` — Gmail user not found.
- `gmail-quota.eml` — Mailbox full (soft bounce).
- `outlook-hard.eml` — Recipient does not exist.
- `outlook-ooo.eml` — Out-of-office auto-reply.
- `postfix-permanent.eml` — Postfix 550.
- `postfix-temporary.eml` — Postfix 451.
- `exchange-quarantine.eml` — Quarantined.
- `gmail-blocked.eml` — Anti-spam block (hard).
Parser must extract: original-recipient address, bounce class
(hard/soft/ooo), reason string, in-reply-to header.
### F.3 — Parser API
New file `src/lib/email/bounce-parser.ts`:
```ts
export interface ParsedBounce {
originalRecipient: string | null;
bounceClass: 'hard' | 'soft' | 'ooo' | 'unknown';
reason: string;
inReplyTo: string | null;
}
export function parseBounce(raw: string | Buffer): ParsedBounce {
/* ... */
}
```
Implementation uses `mailparser` (already in deps) for MIME parsing,
then a switch on `Content-Type` (multipart/report) vs subject-heuristics.
### F.4 — Cron worker
New file `src/jobs/processors/imap-bounce-poller.ts`:
```ts
export async function pollBounces(): Promise<void> {
for (const port of await listPortsWithImap()) {
const cfg = await getPortImapConfig(port.id);
const client = imapflow(cfg);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const messages = client.fetch({ since: oneHourAgo() }, { source: true });
for await (const msg of messages) {
const parsed = parseBounce(msg.source);
if (parsed.originalRecipient) {
await matchAndUpdateDocumentSend(port.id, parsed);
}
}
} finally {
lock.release();
await client.logout();
}
}
}
```
### F.5 — Matching algorithm
`matchAndUpdateDocumentSend(portId, parsed)`:
1. Find `document_sends` row where
`recipient_email = parsed.originalRecipient AND sent_at > now() - interval '7 days' AND bounce_status IS NULL`.
2. If found: update `bounce_status` + `bounce_reason` +
`bounce_detected_at`, fire notification to the sender (user_id from
document_sends.sent_by_user_id).
3. If not found: log + audit (the bounce may be for a stale send or a
non-CRM email).
### F.6 — UI surface
`src/components/interests/interest-emails-tab.tsx` (or wherever sends
render): red banner on rows where `bounce_status IS NOT NULL`. Banner
text: "Email bounced — <reason>".
Notification bell: new type `email_bounced` routed via existing
`createNotification` flow.
### F.7 — Sub-session breakdown
- **6a — Schema + parser + fixtures (2 days)**
- **6b — Cron worker + matching algorithm (1 day)**
- **6c — UI banner + notification + E2E (1 day)**
- **6d — Manual bounce round-trip test (0.5 days)**
---
## Appendix G — Phase 7 (PDF template editor) — comprehensive
### G.1 — Library choices
- **PDF rendering:** `react-pdf` (already in deps). Limit to v7+ to
pick up the Canvas-free rendering path.
- **Coordinate system:** PDF native uses bottom-left origin; viewer
uses top-left. Wrap a single `coordTransformer` utility — never
scatter conversions.
- **Drag handles:** `react-draggable` (small footprint) for marker
movement. Resize via `react-resizable`. Both have stable types.
### G.2 — Schema migration
```sql
-- 0076_pdf_template_field_map.sql
ALTER TABLE document_templates
ADD COLUMN field_map jsonb;
COMMENT ON COLUMN document_templates.field_map IS
'Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>
Coords are percent of page width/height (0..1) so they survive page-size changes.';
```
### G.3 — Editor page
New file `src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx`:
Layout (desktop):
- Left: page picker (vertical thumbnails).
- Centre: PDF page render with overlay canvas for markers + drag handles.
- Right: field-map sidebar listing every marker with edit/delete actions.
- Bottom: "Add field" mode toggle + token autocomplete combobox.
### G.4 — Field-map API
`PUT /api/v1/document-templates/[id]/field-map`:
Request:
```ts
{
fieldMap: Array<{
token: string; // must be in VALID_MERGE_TOKENS
page: number;
x: number; // 0..1
y: number; // 0..1
w: number; // 0..1
h: number; // 0..1
}>;
}
```
Response: `{ data: { id, fieldMap, updatedAt } }`.
Validation:
- Each token must exist in `VALID_MERGE_TOKENS` (rejects typos at the
API boundary — same allow-list pattern as `createTemplateSchema`).
- `0 <= x < 1`, `0 <= y < 1`, `0 < w <= 1 - x`, `0 < h <= 1 - y`.
- `page >= 1`.
- Page count assertion: fetch the source PDF, count pages, reject if
any marker references a page beyond the count.
### G.5 — Preview API
`POST /api/v1/document-templates/[id]/preview`:
Request: `{ interestId: string }`.
Response: `{ data: { previewUrl: string } }` — signed URL (24h TTL) to
a transient PDF filled with the merge-field values pulled from the
specified interest's EoiContext.
Implementation reuses `fillEoiForm` from
`src/lib/pdf/fill-eoi-form.ts` with a per-call coord-list override
from the in-memory edit state.
### G.6 — Live preview wiring
The editor's right pane:
- Debounces edits at 500ms.
- POSTs to the preview endpoint.
- Renders the returned PDF inline via `react-pdf`.
### G.7 — Multi-page navigation
Page picker on the left scrolls the centre to the matching page +
keeps the field-map sidebar filtered to that page's markers.
Edge case: a marker on page 3 of a 5-page template stays visible in
the sidebar but greys out when page 1 is shown — clicking it jumps to
page 3.
### G.8 — New-PDF upload
When admin uploads a replacement PDF:
1. Compute MD5 of old + new PDFs — block upload if identical.
2. Compare page counts. If different, surface a warning modal with
the diff ("Existing template has 5 pages, new has 3. 2 fields on
pages 4+ will be removed.").
3. On confirm: replace source via the existing template-upload flow,
pruning out-of-range fields from `field_map`.
### G.9 — Performance budget
- Largest production template: ~12 pages, ~600KB.
- Editor LCP target: <2s on a 2017 MBP (worst common-case sales rep).
- `react-pdf` worker mode (loadPdfWithWorker) keeps the main thread
responsive during page rendering.
- Field-map state lives in a single `useReducer`; debounced
serialization avoids per-keystroke API hits.
### G.10 — Sub-session breakdown
- **7.1a — PDF render + page picker + read-only viewer (4 days):** No
field placement yet — just confirm `react-pdf` performs well on
production templates and the editor shell renders.
- **7.1b — Field placement (drop marker, save field-map, list) (5 days)**
- **7.1c — Field-map API + validation + tests (3 days)**
- **7.2a — Drag-move + resize markers (3 days)**
- **7.2b — Preview pane + signed-URL serving (4 days)**
- **7.2c — New-PDF upload + diff warning (3 days)**
- **7.2d — Multi-page navigation + edge cases (2 days)**
### G.11 — Open implementation questions
- Should the editor support conditional field placement (e.g.,
"yacht_name" only renders when yacht is set)? Defer to Phase 3.
- Should the editor surface AcroForm fields embedded in the source PDF
separately from CRM-managed markers? Recommend YES — the existing
`assets/eoi-template.pdf` AcroForm flow should keep working alongside
the new percent-coord marker flow. Need a UI toggle to switch view
modes.
- Multi-tenant: should each port have its own template editor URL, or
is templating port-scoped via system_settings? Templates are
port-scoped today, so the editor URL becomes
`/admin/templates/[id]/editor` with port resolution via the existing
port-context middleware.
---
## Cross-phase risks + considerations
1. **Schema migrations are FK-heavy across Phases 3, 4, 6, 7.** Run
`pnpm db:generate` after each, inspect the generated SQL by eye,
apply to dev DB, restart `next dev` (per CLAUDE.md pool-cache note).
2. **Audit-action enum extensions need careful ordering.** Postgres
doesn't allow enum value re-ordering, so the audit display order
relies on a label map (`src/lib/audit-action-labels.ts`). Update
alongside each enum extension.
3. **Per-port admin pages multiply.** After all phases ship, this
adds: `/admin/pulse`, `/admin/templates/[id]/editor`. Confirm the
`<AdminSectionsBrowser>` index covers them.
4. **Worker process additions.** Phase 4 (reminders) and Phase 6
(bounces) both add cron-style jobs. Confirm `Dockerfile.worker`
wakes them up; capture metrics for monitoring.
5. **CLAUDE.md updates.** Each phase that adds doctrine (e.g. EOI
override marker badge, deal-pulse signal types) should land a
matching CLAUDE.md addition in the same PR so the AI assistant
doesn't unlearn the new patterns.
6. **PR sizing.** Each sub-session targets one or two coherent
commits. Avoid mega-PRs — the merge-conflict surface area on
Phase 7 in particular needs small, incremental PRs.
---
## Appendix H — Already-shipped audit residuals (reference)
For traceability, the items completed before this plan started:
- Audit fix waves: 3/3 CRITICAL, 14/15 HIGH (1 N/A), 28+ MEDIUM, 6/8
LOW (commits `4b5f85c`, `0f99f05`).
- Documenso v2 polish: envelope-ID sync, signing-progress redesign,
20+ UX fixes.
- env→admin migration: 30+ registry vars, per-port encryption,
5 admin pages converted.
Master plan picks up after these.
---
## Definition of done (cross-phase)
A phase is considered shipped when:
- All sub-sessions are ticked in the §"Phase ☑/☐ tracker" above.
- `pnpm exec vitest run` passes.
- `pnpm tsc --noEmit` passes.
- `pnpm lint` passes.
- For phases touching middleware/env/build config: `pnpm build` passes.
- For UI-facing phases: at least one smoke E2E spec is added (or an
existing spec extended) under `tests/e2e/smoke/`.
- CLAUDE.md updated with any new doctrine.
- This master plan is updated — phase marked ☑ with a one-line
outcome note inline.
- ☐ 7.2 Edit + preview