From c9debce4420463a404dc610efaa3d74c0e0f97cf Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 18 May 2026 14:43:12 +0200 Subject: [PATCH] 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) --- docs/MASTER-PLAN-2026-05-18.md | 836 +++++++++++++++++++++++++++++++++ 1 file changed, 836 insertions(+) create mode 100644 docs/MASTER-PLAN-2026-05-18.md diff --git a/docs/MASTER-PLAN-2026-05-18.md b/docs/MASTER-PLAN-2026-05-18.md new file mode 100644 index 00000000..2e7d507c --- /dev/null +++ b/docs/MASTER-PLAN-2026-05-18.md @@ -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//` 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/`, + `/sign/contract/`, `/sign/reservation/` 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__enabled`. +3. **Label rename map** — `pulse_label_` 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__enabled boolean default true` for each signal + - `pulse_label_ 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 + +- `` 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 `` — + 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 + +- `` — each editable field becomes a `` + 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 (``, per CLAUDE.md doctrine) with the + existing ``. 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 `` — 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). +- ``: `[+ 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 + +- `` row gets a red border + "Bounced: " + 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