docs(plan): comprehensive 7-phase master plan for post-audit work
Single source of truth for all remaining audit + feature work: Documenso completion, deal-pulse signals + admin config, EOI overrides, Reminders, email-copy refactor, IMAP bounce linking, PDF editor. Each phase carries goal, scope, schema, API/UI surfaces, acceptance criteria, test plan, effort estimate, and a sub-task tracker that fresh sessions tick through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
836
docs/MASTER-PLAN-2026-05-18.md
Normal file
836
docs/MASTER-PLAN-2026-05-18.md
Normal file
@@ -0,0 +1,836 @@
|
||||
# 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).
|
||||
|
||||
---
|
||||
|
||||
## Phase ☑/☐ tracker
|
||||
|
||||
- ☐ Phase 1 — Documenso completion + Supplemental form
|
||||
- ☐ 1.1 Documenso Phase 7 (RBAC)
|
||||
- ☐ 1.2 Documenso Phase 2 (Webhook UX)
|
||||
- ☐ 1.3 Documenso Phase 5 (Embedded signing)
|
||||
- ☐ 1.4 Supplemental form per-port
|
||||
- ☐ Phase 2 — Deal-pulse signals + admin config UI
|
||||
- ☐ Phase 3 — EOI field overrides
|
||||
- ☐ 3a — Schema + APIs
|
||||
- ☐ 3b — EOI form UI
|
||||
- ☐ 3c — Yacht spawn
|
||||
- ☐ 3d — Polish + audit surfacing
|
||||
- ☐ Phase 4 — Reminders
|
||||
- ☐ Phase 5 — Email-copy refactor
|
||||
- ☐ Phase 6 — IMAP bounce-to-interest linking
|
||||
- ☐ Phase 7 — PDF template editor
|
||||
- ☐ 7.1 Read + place
|
||||
- ☐ 7.2 Edit + preview
|
||||
Reference in New Issue
Block a user