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>
1920 lines
73 KiB
Markdown
1920 lines
73 KiB
Markdown
# 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 | ~9–10h | none |
|
||
| 2 | Deal-pulse signal expansion + admin config UI | ~5–6h | none |
|
||
| 3 | EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) | ~1–1.5 weeks | none |
|
||
| 4 | Reminders (reminder_note + standalone tasks + per-user TOD) | ~3–4 days | none |
|
||
| 5 | §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) | ~5–7 days | requires old-CRM reference |
|
||
| 6 | M-EM03 IMAP bounce-to-interest linking | ~3–5 days | none |
|
||
| 7 | PDF template editor (Phases 1+2) | ~3–4 weeks | none |
|
||
|
||
**Total visible work:** ~7–8 weeks of focused development at 1 phase at a time.
|
||
Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are
|
||
medium; Phase 7 is the long one.
|
||
|
||
---
|
||
|
||
## Phase 1 — Documenso completion + Supplemental form per-port
|
||
|
||
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §3 (Supplemental form) + §4 (Documenso 2/5/7).
|
||
Bundled because both touch admin UIs under `/admin/documenso/` and `/admin/email/`.
|
||
|
||
### 1.1 Documenso Phase 7 — Project Director RBAC (~1h)
|
||
|
||
**Goal:** When a Documenso event arrives for the developer or approver
|
||
signer, also notify the linked CRM user in their inbox.
|
||
|
||
**Scope in:**
|
||
|
||
- Add "Linked to CRM user" dropdowns to `/admin/documenso/page.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 (~3–4h)
|
||
|
||
**Goal:** Sequential signing fires "your turn" emails to signer N+1
|
||
when signer N completes; on COMPLETED, distribute signed PDF to all CC
|
||
emails; tighten idempotency.
|
||
|
||
**Scope in:**
|
||
|
||
- Cascading invite: in `handleDocumentSigned`, look up the next
|
||
pending recipient (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 (~1–2h)
|
||
|
||
**Goal:** Confirm the marketing site's `/sign/<type>/<token>` route
|
||
handles every signer-role × documentType combo; tighten role-specific
|
||
copy in invitation emails.
|
||
|
||
**Scope in:**
|
||
|
||
- Audit `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
|
||
|
||
~9–10 hours. Ships as 4 commits in a single PR.
|
||
|
||
---
|
||
|
||
## Phase 2 — Deal-pulse signal expansion + admin config UI
|
||
|
||
**Reference:** `deal-pulse-trigger-audit.md` (call-site inventory).
|
||
|
||
### Goal
|
||
|
||
The deal-pulse chip currently shows momentum (stage advancement,
|
||
time-in-stage) but ignores high-value pipeline signals. Expand the
|
||
signal set + give admins per-port control over which signals fire,
|
||
what labels they show, and tier thresholds.
|
||
|
||
### Signal additions
|
||
|
||
**Positive (brighten chip):**
|
||
|
||
- `eoi_sent` — fires when EOI status transitions 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
|
||
|
||
~5–6h. One PR.
|
||
|
||
---
|
||
|
||
## Phase 3 — EOI field overrides
|
||
|
||
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §1 (base spec) + user
|
||
clarifications below.
|
||
|
||
### Goal
|
||
|
||
When generating an EOI, rep can override pre-filled fields (contact
|
||
info, addresses, yacht name + dimensions) from a dropdown of every
|
||
known value for that channel. Manual entries persist as tracked
|
||
secondary values; future EOIs can pick them from the dropdown. Yacht
|
||
overrides spawn a new yacht record linked to the same interest/client.
|
||
|
||
### User clarifications captured
|
||
|
||
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 (~1–2 days):** Polish — audit log surfacing in
|
||
audit-log UI, badges/labels in notification copy, documentation.
|
||
|
||
### Risks
|
||
|
||
- Schema migration is FK-heavy. Run `pnpm db: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
|
||
|
||
~1–1.5 weeks. Split into 4 sub-sessions per the breakdown above.
|
||
|
||
---
|
||
|
||
## Phase 4 — Reminders
|
||
|
||
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §2.
|
||
|
||
### Goal
|
||
|
||
Reps can: (a) attach a follow-up note to interest cadences, (b) create
|
||
standalone tasks not tied to an entity, (c) assign tasks to other reps,
|
||
(d) configure their default firing time-of-day with per-row override.
|
||
|
||
### Schema additions
|
||
|
||
- `interests.reminder_note text NULL` — surfaced in notification body
|
||
and inbox row.
|
||
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`.
|
||
- `reminders.fired_at timestamptz NULL` — drives worker idempotency.
|
||
- No new tables. `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
|
||
|
||
~3–4 days. One PR.
|
||
|
||
---
|
||
|
||
## Phase 5 — §11.3 Email-copy refactor
|
||
|
||
**Reference:** old-CRM Nuxt repo at
|
||
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
|
||
(notable: `server/utils/email.ts`, `server/tasks/process-sales-emails.ts`,
|
||
`components/EmailComposer.vue`).
|
||
|
||
### Goal
|
||
|
||
Modernize email tone to luxury-port voice; audit per-port branding
|
||
chain (logo, signature block, footer copy); ensure every automated
|
||
email uses the per-port branded shell.
|
||
|
||
### Scope in
|
||
|
||
- **Tone pass:** Rewrite every template in `src/lib/email/templates/`
|
||
using the old-CRM templates as voice reference (open them, capture
|
||
cadence + phrasing, port to current React-email or HTML-string
|
||
format).
|
||
- **Branding chain audit:** Walk every sender callsite (`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
|
||
|
||
~5–7 days. The grunt is the tone rewrite (each template needs
|
||
~30–45 min of focused copywriting + review). One PR with the
|
||
template files; CI snapshot tests gate it.
|
||
|
||
---
|
||
|
||
## Phase 6 — M-EM03 IMAP bounce-to-interest linking
|
||
|
||
**Reference:** Phase 7 §14.9 in the original system spec.
|
||
|
||
### Goal
|
||
|
||
When an outbound sales email bounces (NDR returned via IMAP), match
|
||
the bounce to the originating `document_sends` row and surface a
|
||
warning on the linked interest's email tab.
|
||
|
||
### Scope in
|
||
|
||
- Bounce parser in `src/lib/email/bounce-parser.ts` — extract original
|
||
recipient + bounce reason from common NDR formats (Gmail, Outlook,
|
||
Postfix, Exchange).
|
||
- Cron job in `src/jobs/processors/imap-bounce-poller.ts` — polls
|
||
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
|
||
|
||
~3–5 days. Parser fixtures are the longest tail.
|
||
|
||
---
|
||
|
||
## Phase 7 — PDF template editor (Phases 1+2)
|
||
|
||
**Reference:** `berth-recommender-and-pdf-plan.md` (background +
|
||
infrastructure context).
|
||
|
||
### Goal
|
||
|
||
A web-based PDF template editor that lets admins:
|
||
|
||
- Phase 1: View an existing PDF template, click on a page region to
|
||
drop a merge-field marker, save the field map.
|
||
- Phase 2: Edit existing fields (move, resize, delete), upload a new
|
||
PDF (replacing the source), live-preview the AcroForm fill.
|
||
|
||
This replaces the current "edit the template PDF in Acrobat, re-upload"
|
||
workflow with an in-app editor.
|
||
|
||
### Phase 7.1 — Read + place (Phase 1, ~2 weeks)
|
||
|
||
**Scope in:**
|
||
|
||
- New admin page `/admin/templates/[id]/editor/page.tsx`.
|
||
- PDF viewer using `react-pdf` (already in deps for invoice rendering).
|
||
- Field marker overlay: click on a region → enter merge-field token
|
||
name → marker persists in `document_templates.field_map JSONB`.
|
||
- Token autocomplete from `VALID_MERGE_TOKENS`
|
||
(`src/lib/templates/merge-fields.ts`).
|
||
- Save endpoint: `PUT /api/v1/document-templates/:id/field-map`.
|
||
|
||
**Scope out:**
|
||
|
||
- Editing existing AcroForm fields (separate workflow).
|
||
- Multi-page navigation (Phase 1 = page 1 only).
|
||
- Conditional fields, signatures, repeating sections.
|
||
|
||
**Data:**
|
||
|
||
- `document_templates.field_map JSONB NULL` —
|
||
`Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>`.
|
||
|
||
**API:**
|
||
|
||
- `PUT /api/v1/document-templates/:id/field-map`.
|
||
|
||
**UI:** Full editor page; uses Sheet for token picker side-panel.
|
||
|
||
**Acceptance:**
|
||
|
||
- Admin places 3 markers on a sample PDF; saves; reopens; markers
|
||
persist at the right coords.
|
||
- Generating a doc from the template fills the AcroForm at those
|
||
coords with the merge-field values.
|
||
|
||
**Effort:** ~2 weeks.
|
||
|
||
### Phase 7.2 — Edit + preview (Phase 2, ~1–2 weeks)
|
||
|
||
**Scope in:**
|
||
|
||
- Drag-to-move existing markers.
|
||
- Drag-corner-to-resize markers.
|
||
- Delete marker via right-click → "Remove field".
|
||
- Live preview pane (right side) showing the AcroForm fill with
|
||
sample data from a chosen interest.
|
||
- Multi-page navigation (page picker top-left).
|
||
- New-PDF upload: replace the source file while preserving the
|
||
field map (warn if coord ranges shift).
|
||
|
||
**Scope out:**
|
||
|
||
- Conditional fields / signatures (defer to Phase 3, unscoped).
|
||
|
||
**Data:** No new schema; reuses `field_map` JSONB.
|
||
|
||
**API:**
|
||
|
||
- `POST /api/v1/document-templates/:id/preview` — accepts an
|
||
interest ID, returns a presigned URL to a transient preview PDF.
|
||
|
||
**UI:** Editor extends — page picker, preview pane, drag handlers,
|
||
right-click context menu.
|
||
|
||
**Acceptance:**
|
||
|
||
- Drag a marker → save → reopen → marker is at the new coords.
|
||
- Resize marker → field rendering box matches new dims.
|
||
- Upload replacement PDF → field map preserved; warning shown if
|
||
page count changed.
|
||
- Live preview reflects current field map within 2s of edits.
|
||
|
||
**Effort:** ~1–2 weeks.
|
||
|
||
### Test plan (both phases)
|
||
|
||
- Unit: field-map serialization + coord persistence.
|
||
- Integration: PUT field-map → re-GET → exact roundtrip.
|
||
- E2E: admin places marker, generates doc, signed PDF has value at
|
||
expected coord (within tolerance).
|
||
|
||
### Risks
|
||
|
||
- `react-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
|
||
|
||
~3–4 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
|
||
3–5 representative templates and quote them in the PR description
|
||
for reviewer traceability.
|
||
- **Phase 7:** Confirm `react-pdf` performance budget on the largest
|
||
template currently in production (capture page-count + LCP).
|
||
|
||
---
|
||
|
||
## Session log
|
||
|
||
### Session 2026-05-18 PM — Phases 4 / 2 wiring / 6 / CLAUDE.md
|
||
|
||
Three of the four "suggested execution order" items shipped; Phase 3b
|
||
was deferred (effort estimate exceeded remaining session time).
|
||
|
||
Recent commits leading into this session:
|
||
|
||
```
|
||
a6e7923 docs(plan): mark Phase 1+2 ☑, Phase 3-7 ◐ partial
|
||
df1594d feat(email): Phase 5 — branding chain ext'd with per-port background
|
||
9f57868 feat(post-audit): Phase 3/6/7 schema foundations + bounce parser
|
||
fb4a09e feat(reminders): Phase 4 partial — schema + service + validators
|
||
918c23f feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin
|
||
ee3cbb9 docs(plan): expand master plan with detailed implementation appendix
|
||
c9debce docs(plan): comprehensive 7-phase master plan for post-audit work
|
||
0f99f05 feat(post-audit): batch A+B quick-wins + audit-side residuals
|
||
4b5f85c fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
|
||
397dbd1 docs(spec): env-to-admin migration design
|
||
```
|
||
|
||
Shipped this session:
|
||
|
||
- ☑ CLAUDE.md trimmed 27KB → ~19.5KB; added Tools/Skills/MCPs section.
|
||
- ☑ Phase 4 polish — yachtId field on `<ReminderForm>` + Ship subtitle on `<ReminderCard>` + `listReminders` filter + `getReminder` yacht relation join.
|
||
- ☑ Phase 2 risk-signal data wiring — derivation pass in `getInterestById` (3 parallel queries) populates the 3 risk-signal dates from `document_events` / `berth_reservations` / cross-interest `interest_berths`. Chosen over new schema columns; documented in CLAUDE.md.
|
||
- ☑ Phase 6 cron + UI — `imap-bounce-poller.ts` worker wired into maintenance queue at `*/15 * * * *`; matches NDRs to recent `document_sends` rows, fires `email_bounced` notification on hard/soft; admin `/admin/sends` page now shows bounce badge + reason banner.
|
||
- Quality gates: 1374/1374 vitest pass, `tsc --noEmit` clean, `pnpm lint` zero errors (37 pre-existing warnings).
|
||
|
||
Deferred:
|
||
|
||
- Phase 3b — EOI dialog override UI (combobox per field + 2 checkboxes) was the 4th item; master-plan estimate is 2-3 days and exceeded remaining session time.
|
||
- Phase 4 worker scheduler refactor (fired_at gate cron tick).
|
||
- Phase 6 interest-detail "Emails" tab — the tab surface doesn't exist yet; bounce banner will live there when the tab lands.
|
||
|
||
---
|
||
|
||
## Phase ☑/☐ tracker
|
||
|
||
- ☑ Phase 1 — Documenso completion + Supplemental form (commits df1594d, 918c23f)
|
||
- ☑ 1.1 Documenso Phase 7 (RBAC) — already in code prior; verified at documents.service.ts:1268-1300
|
||
- ☑ 1.2 Documenso Phase 2 (Webhook UX cascading invite) — already in code prior; verified
|
||
- ☑ 1.3 Documenso Phase 5 (Embedded signing) — copy made order-agnostic + developer-role branch
|
||
- ☑ 1.4 Supplemental form per-port URL — registry + getPortEmailConfig + route
|
||
- ☑ Phase 2 — Deal-pulse signals + admin config UI (918c23f, plus session 2026-05-18 PM)
|
||
- Compute extended with 3 positive + 3 risk signals; admin page mounted at /admin/pulse
|
||
- ☑ Data-wiring: derivation pass inside `getInterestById` — runs 3 parallel queries
|
||
against `document_events` (rejected/declined), `berth_reservations`
|
||
(status='cancelled'), and other `won` interests sharing a berth via `interest_berths`.
|
||
Returns the 3 dates on the API response; `interest-detail-header` threads them
|
||
through to `<DealPulseChip>`. Chosen over new schema columns to keep the master
|
||
plan's "no new tables" promise. Documented in CLAUDE.md.
|
||
- ◐ Phase 3 — EOI field overrides (9f57868 + session 2026-05-18 PM)
|
||
- ☑ 3a — Schema migration 0073, Drizzle additions, audit_actions free-text verbs
|
||
- ☑ 3b — EOI dialog UI overrides for email/phone/yacht-name; service-level
|
||
side-effects (create non-primary contact, promote-to-primary, write
|
||
documents.override\_\*) inside a single transaction via
|
||
`src/lib/services/eoi-overrides.service.ts`. Both pathways (inapp +
|
||
Documenso template) layer overrides onto the in-memory EoiContext
|
||
before render. Audit verbs `eoi_field_override` + `promote_to_primary`
|
||
- `eoi_spawn_yacht` formalised in `src/lib/audit.ts`. Address
|
||
overrides + per-yacht detail badge deferred.
|
||
- ☑ 3c — "+ New yacht" button next to yacht-name field opens nested
|
||
`<YachtForm>` Sheet (pre-fills owner = current client, stamps
|
||
`source='eoi-generated'`); on save, the interest's yachtId is patched
|
||
so the EOI's yacht block populates without a manual re-link.
|
||
- ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary`
|
||
(transactional demote+promote via `promoteContactToPrimary`); `[EOI]`
|
||
badge on non-primary contact rows in `<ContactsEditor>` + on yacht
|
||
detail header when `yacht.source === 'eoi-generated'`.
|
||
- ☐ Address override field in EOI dialog (schema columns exist)
|
||
- ☐ Audit-log UI surfacing of new verbs (rows written, filter chips missing)
|
||
- ☐ Backfill yachts.source_document_id after EOI document is created
|
||
(currently set NULL because the yacht is spawned BEFORE the doc row exists)
|
||
- ◐ Phase 4 — Reminders (fb4a09e + session 2026-05-18 PM)
|
||
- ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note
|
||
- ☑ Service + validators accept yachtId with port-scoping check
|
||
- ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope)
|
||
- ☑ `<ReminderCard>` shows yacht subtitle (Ship icon + yacht name)
|
||
- ☑ `listReminders` filters by query.yachtId; `getReminder` joins yacht relation
|
||
- ☑ Worker `processOverdueReminders` claims due rows via `UPDATE...RETURNING`
|
||
with `fired_at IS NULL` race-safe gate, so parallel workers can't
|
||
double-fire the same reminder.
|
||
- ☑ `user_profiles.preferences.digestTimeOfDay` picker on `/settings`
|
||
(time input + help text). `<ReminderForm>` honours the preference via
|
||
a React-Query me-prefs fetch keyed `['me', 'preferences']`.
|
||
- ☑ Per-entity `[+ Reminder]` buttons on yacht / client / interest detail
|
||
headers threading defaultYachtId / defaultClientId / defaultInterestId
|
||
- ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD)
|
||
- ◐ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM)
|
||
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
|
||
- ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation
|
||
- reset, inquiry-client-confirmation, notification-digest, document-signing
|
||
sign-offs). Voice captured from old-CRM Nuxt repo `server/utils/
|
||
signature-notifications.ts` ("Dear X", "With warm regards, The
|
||
{portName} Team").
|
||
- ☐ Remaining 4 templates: admin-email-change, crm-invite,
|
||
inquiry-sales-notification, residential-inquiry
|
||
- ☐ Snapshot tests per template at port-nimara + 2nd test port
|
||
- ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM)
|
||
- ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends
|
||
- ☑ Parser library `src/lib/email/bounce-parser.ts` (RFC 3464 + Outlook + OOO)
|
||
- ☑ Cron worker `src/jobs/processors/imap-bounce-poller.ts` — reads IMAP\__ env,
|
||
matches NDR recipient to recent document_sends, idempotent via `bounceDetectedAt`,
|
||
fires `email_bounced` notification on hard/soft (skips OOO); state persisted to
|
||
`system_settings.bounce_poller_state` (port_id=NULL). Wired into maintenance
|
||
queue at `_/15 \* \* \* \*`.
|
||
- ☑ UI banner on `/admin/sends` (admin sends-log) + `email_bounced` notification type
|
||
- ☐ Interest-detail "Emails" tab — surface tab doesn't exist yet; bounce banner
|
||
would live there when the tab lands (deferred to a wider emails-surface session)
|
||
- ☐ Manual round-trip test against real bounced delivery
|
||
- **Workspace activation:** set `IMAP_HOST=imap.gmail.com`,
|
||
`IMAP_PORT=993`, `IMAP_USER=<workspace-account>`, `IMAP_PASS=<app-password>`
|
||
in the worker env. App Passwords are generated at Account → Security
|
||
→ 2-Step Verification → App passwords. Google displays the password
|
||
as **16 characters in 4 groups of 4 separated by spaces** (e.g.
|
||
`abcd efgh ijkl mnop`). Per Google's own docs the spaces are visual
|
||
only — paste the 16-char unbroken string into `.env`. The poller
|
||
strips whitespace defensively (`src/jobs/processors/imap-bounce-poller.ts`)
|
||
so a copy-paste with spaces still works. Bounces land in the
|
||
envelope sender's mailbox (the SMTP user account), so pointing the
|
||
poller at that single mailbox catches every automated-email bounce
|
||
in one place.
|
||
- ◐ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM)
|
||
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator
|
||
- ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side
|
||
`<TemplateEditor>` with react-pdf, click-to-place markers, token picker
|
||
from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. Page 1
|
||
only; add + delete markers supported.
|
||
- ☐ 7.1 polish: unsaved-changes guard, responsive PDF width,
|
||
"required tokens unplaced" indicator
|
||
- ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with
|
||
sample interest data, multi-page navigation, new-PDF upload (replace
|
||
source while preserving field map)
|
||
|
||
---
|
||
|
||
# Detailed Implementation Appendix
|
||
|
||
This appendix expands every phase with per-file change lists, schema
|
||
migration SQL skeletons, API request/response shapes, and component
|
||
breakdowns. Anything ambiguous in the phase summaries above is resolved
|
||
here. Read this in conjunction with the phase header.
|
||
|
||
---
|
||
|
||
## Appendix A — Phase 1 (Documenso completion + Supplemental form)
|
||
|
||
### A.1 — Status of each sub-phase against existing code
|
||
|
||
A grep + read pass at the time of writing this appendix confirmed:
|
||
|
||
- **1.1 Project Director RBAC notification → already in code**
|
||
(`src/lib/services/documents.service.ts:1268-1300`). Registry keys
|
||
`documenso_developer_user_id` + `documenso_approver_user_id` exist
|
||
(`src/lib/settings/registry.ts:116, 162`). Admin UI renders them via
|
||
`<RegistryDrivenForm sections={['documenso.signers']}>` with the
|
||
`user-select` field type (`registry-driven-form.tsx:499-507`).
|
||
→ **Verification only.** Smoke test by linking a CRM user on a port,
|
||
triggering a recipient-signed webhook for the matching role, and
|
||
asserting the linked user receives a `document_signing_your_turn`
|
||
notification in their inbox.
|
||
- **1.2 Cascading invite to next signer → already in code**
|
||
(`sendCascadingInviteForNextSigner` at `documents.service.ts:1220`).
|
||
→ **Verification only.** Send a 3-signer sequential EOI, sign
|
||
recipient 1, assert recipient 2 receives a branded "your turn"
|
||
email within 30s.
|
||
- **1.3 Embedded signing copy + nginx CORS → partial.** Signing
|
||
invitation copy lives in `src/lib/email/templates/` — needs a
|
||
grep for the actual file path. nginx config: confirm if owned by
|
||
this repo or the ops repo. → **Implementation needed.**
|
||
- **1.4 Supplemental form per-port URL → not started.** Existing
|
||
service at `src/lib/services/supplemental-forms.service.ts` mints
|
||
tokens for the CRM-hosted `/supplemental/[token]` route. → **Full
|
||
implementation needed.**
|
||
|
||
### A.2 — Supplemental form per-port: per-file change list
|
||
|
||
1. **`src/lib/settings/registry.ts`** — Add a new entry:
|
||
|
||
```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 ~3–4h:
|
||
|
||
- 1.3 signing-invitation copy audit: ~1h
|
||
- 1.3 nginx CORS: 5min if it's already documented, ~30min if not
|
||
- 1.4 supplemental form: ~2h
|
||
- Tests + smoke: ~30min
|
||
|
||
---
|
||
|
||
## Appendix B — Phase 2 (Deal-pulse signals + admin config UI)
|
||
|
||
### B.1 — Schema migration SQL
|
||
|
||
```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 3–5 representative
|
||
template strings, quote in PR description for reviewer traceability.
|
||
|
||
### E.2 — Templates to refactor (per-file)
|
||
|
||
Current `src/lib/email/templates/`:
|
||
|
||
- `portal-auth.ts` — activation + reset (already branded, voice pass needed)
|
||
- `signing-invitation.ts` — voice-pass + role-specific copy completeness 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
|