Files
pn-new-crm/docs/MASTER-PLAN-2026-05-18.md
Matt c9debce442 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>
2026-05-18 14:43:12 +02:00

837 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Master Implementation Plan — Post-Audit Remediation & Feature Expansion
**Created:** 2026-05-18
**Status:** Active — sequenced across multiple sessions
**Companion docs:**
- `POST-AUDIT-SPEC-2026-05-18.md` — original design decisions (this doc extends + supersedes)
- `AUDIT-FIX-WAVE-2026-05-18.md` — what already shipped
- `deal-pulse-trigger-audit.md` — call-site inventory for §1.2 signal expansion
- `eoi-documenso-field-mapping.md` — token → AcroForm map for §1.3 EOI overrides
- `berth-recommender-and-pdf-plan.md` — prior PDF infrastructure context
This is the single source of truth for everything outstanding. Each
phase is self-contained: a fresh session can pick up any phase and ship
it without re-reading the others. Phases are ordered by dependency +
ship-size; bigger features can be split across sessions inside their
own phase boundary.
---
## Sequencing summary
| # | Phase | Effort | Depends on |
| --- | ---------------------------------------------------------------------------- | ------------ | -------------------------- |
| 1 | Documenso completion (7 → 2 → 5) + Supplemental form per-port | ~910h | none |
| 2 | Deal-pulse signal expansion + admin config UI | ~56h | none |
| 3 | EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) | ~11.5 weeks | none |
| 4 | Reminders (reminder_note + standalone tasks + per-user TOD) | ~34 days | none |
| 5 | §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) | ~57 days | requires old-CRM reference |
| 6 | M-EM03 IMAP bounce-to-interest linking | ~35 days | none |
| 7 | PDF template editor (Phases 1+2) | ~34 weeks | none |
**Total visible work:** ~78 weeks of focused development at 1 phase at a time.
Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are
medium; Phase 7 is the long one.
---
## Phase 1 — Documenso completion + Supplemental form per-port
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §3 (Supplemental form) + §4 (Documenso 2/5/7).
Bundled because both touch admin UIs under `/admin/documenso/` and `/admin/email/`.
### 1.1 Documenso Phase 7 — Project Director RBAC (~1h)
**Goal:** When a Documenso event arrives for the developer or approver
signer, also notify the linked CRM user in their inbox.
**Scope in:**
- Add "Linked to CRM user" dropdowns to `/admin/documenso/page.tsx` for
the existing `developer_user_id` and `approver_user_id` system_settings.
- Auto-fill name/email when a user is selected (read via existing
`/api/v1/admin/users/picker`).
- Webhook handler additions in `src/app/api/webhooks/documenso/route.ts`:
when an event matches the developer/approver, also emit a
`documenso:signed` notification routed to the linked CRM user.
**Scope out:**
- Permissions changes (using existing notification routing).
- New audit*log actions (existing `documenso_webhook*\*` covers it).
**Data:** No schema change. `system_settings.developer_user_id` and
`approver_user_id` already exist.
**API:** No new routes. Reuses `/api/v1/admin/users/picker`.
**UI:** Two new fields in the Documenso admin page (left column,
below the existing developer name/email pair).
**Acceptance:**
- Selecting a CRM user fills the name + email fields automatically.
- Test webhook fires: linked user sees a notification in their inbox.
- Unlink (select "None"): no notification fires.
**Test plan:**
- Unit: webhook router resolves user_id → notification target.
- E2E (smoke): admin can link/unlink users; UI updates persist.
### 1.2 Documenso Phase 2 — Webhook handler enhancement (~34h)
**Goal:** Sequential signing fires "your turn" emails to signer N+1
when signer N completes; on COMPLETED, distribute signed PDF to all CC
emails; tighten idempotency.
**Scope in:**
- Cascading invite: in `handleDocumentSigned`, look up the next
pending recipient (next `recipientId` in order with `signed_at IS
NULL`) and queue a `sendSigningInvitation` for that signer.
Sequential mode only (check `signing_order`).
- On-completion CC distribution: in `handleDocumentCompleted`, after
the PDF is downloaded and saved to files, email each
`documents.completion_cc_emails` row with the signed PDF as a
download link (signed URL, 24h TTL).
- Token-based matching: prefer `signing_token` over email for
webhook → recipient resolution; falls back to email-only when
token is absent.
- Idempotency: composite unique constraint
`(documensoDocumentId, recipientEmail, eventType)` on `documentEvents`;
replaces the current body-hash dedup.
**Scope out:**
- Parallel-mode invite flow (already covered by initial distribution).
- Self-hosted PDF attachment (link-only — keeps emails light, see
CLAUDE.md note on email_attach_threshold_mb).
**Data:**
- Migration: drop body-hash unique index on `documentEvents`, add
`unique(documensoDocumentId, recipientEmail, eventType)`. Migration
is reversible — the body-hash column stays.
**API:** No new routes. Internal webhook handler only.
**UI:** No change.
**Acceptance:**
- Sequential 3-signer doc: signer 1 signs → signer 2 receives invite
email; signer 2 signs → signer 3 receives invite; signer 3 signs →
COMPLETED fires and CC list gets the signed PDF link.
- Duplicate webhook retries are no-ops (composite key blocks insert).
- Parallel-mode doc: no cascade (all signers got their invite at send).
**Test plan:**
- Integration: mock 3-signer sequential webhook stream, assert email
count + distribution.
- Integration: COMPLETED webhook with CC list, assert link email per CC.
- Unit: idempotency composite key rejects duplicates.
### 1.3 Documenso Phase 5 — Embedded signing URL verification (~12h)
**Goal:** Confirm the marketing site's `/sign/<type>/<token>` route
handles every signer-role × documentType combo; tighten role-specific
copy in invitation emails.
**Scope in:**
- Audit `signerMessages` map in
`src/lib/email/templates/signing-invitation.ts` — fill gaps for
every (role, documentType) pair currently in production.
- nginx CORS block: constrain Documenso webhook receiver origin
(config-only, no code change).
- Manual verification pass: walk through `/sign/eoi/<token>`,
`/sign/contract/<token>`, `/sign/reservation/<token>` for each
signer role (client / approver / developer). Document missing
states in a quick checklist.
**Scope out:**
- New embed surfaces (current routes are sufficient).
- CSP changes (handled in `src/proxy.ts` already).
**Data:** None.
**API:** None.
**UI:** Copy-only changes in invitation email body.
**Acceptance:**
- Each role × doc-type combo renders the correct welcome copy.
- nginx config blocks unknown origins on the webhook receiver
(verified by curl from a non-Documenso IP).
**Test plan:**
- Snapshot tests on email template rendering for each
(role, documentType) tuple.
- Manual walkthrough checklist landed in PR description.
### 1.4 Supplemental form per-port setting (~2h)
**Goal:** "Send supplemental info form" link in auto-emails resolves
to a marketing-site URL when configured per-port; falls back to the
CRM-hosted `/supplemental/[token]` route otherwise.
**Scope in:**
- New `system_settings.supplemental_form_url` key (per-port, optional,
text). Schema already supports arbitrary keys.
- Email link generator in `src/lib/services/sales-emails.ts` (or
wherever the supplemental-info email is composed):
```ts
const url = cfg.supplementalFormUrl
? `${cfg.supplementalFormUrl}?token=${raw}`
: `${env.APP_URL}/supplemental/${raw}`;
```
- Admin UI: add the field to `src/app/(dashboard)/[portSlug]/admin/email/page.tsx`
as a single text input with help hint "Leave blank to use the
built-in CRM page."
- CRM fallback route `/supplemental/[token]/page.tsx`: confirm it still
renders (already exists). Add dual-mode "If you don't see your
details, contact your rep" hint.
**Scope out:** Token format changes; the existing token scheme works
for both modes.
**Data:** New system_settings key only.
**API:** No new routes.
**UI:** One new input on `/admin/email/page.tsx`.
**Acceptance:**
- With URL configured: email link points at marketing site with token
query param.
- With URL blank: email link points at CRM route.
- Token roundtrips through both modes successfully.
**Test plan:**
- Unit: link resolver returns expected URL for both cases.
- Integration: send-out flow with each config variant.
### Phase 1 total effort
~910 hours. Ships as 4 commits in a single PR.
---
## Phase 2 — Deal-pulse signal expansion + admin config UI
**Reference:** `deal-pulse-trigger-audit.md` (call-site inventory).
### Goal
The deal-pulse chip currently shows momentum (stage advancement,
time-in-stage) but ignores high-value pipeline signals. Expand the
signal set + give admins per-port control over which signals fire,
what labels they show, and tier thresholds.
### Signal additions
**Positive (brighten chip):**
- `eoi_sent` — fires when EOI status transitions to `sent`.
Already a call-site in `documents.service.ts` for stage auto-advance;
hook a `pulseSignals.push({ kind: 'eoi_sent', at: now })` next to it.
- `deposit_received` — fires when an invoice with `purpose = 'deposit'`
flips to `status = 'paid'`. Hook in `invoices.service.ts:markPaid`.
- `contract_signed` — fires when a `documents` row with
`templateType = 'contract'` flips to `status = 'completed'`. Hook in
webhook handler `handleDocumentCompleted`.
**Negative (darken chip):**
- `document_declined` — fires when any doc on the interest flips to
`status IN ('declined', 'rejected')`. Hook in
`handleDocumentRejected` (already exists from Phase A).
- `reservation_cancelled` — fires when a `reservations` row flips to
`status = 'cancelled'`. Hook in `reservations.service.ts`.
- `berth_sold_to_other` — fires when the interest's primary berth gets
linked to a different completed interest. Hook in
`interest-berths.service.ts:upsertInterestBerth` when the conflicting
link is detected.
**Cadence tiers:**
- Today: stale flag fires at >7 days in same stage.
- New: tier system reading per-port thresholds:
- `pulse_cadence_warning_days` (default 7) → "Quiet"
- `pulse_cadence_critical_days` (default 21) → "At Risk"
- `pulse_cadence_terminal_days` (default 45) → "Critical"
### Admin config UI
New page `/admin/pulse/page.tsx` (or subsection of `/admin/sales/`):
1. **Master toggle** (`pulse_enabled`, default `true`): off → chip hides
on every interest list/detail surface.
2. **Per-signal toggles** — checkbox per signal, all default on. Stored
as `pulse_signal_<name>_enabled`.
3. **Label rename map** — `pulse_label_<key>` text fields for: "Hot",
"Quiet", "At Risk", "Critical", "EOI sent", "Deposit paid",
"Contract signed", "Declined", "Reservation cancelled",
"Berth resold". Empty value = use built-in default.
4. **Cadence threshold inputs** — three numeric inputs for the day
thresholds above.
5. **Weight tuning** — already partially exists as `heat_weight_*` keys.
Move into this page as a sub-section.
### Schema additions
- New system_settings keys (per-port, all optional, all read with
defaults):
- `pulse_enabled boolean default true`
- `pulse_signal_<name>_enabled boolean default true` for each signal
- `pulse_label_<key> text` for each renamable label
- `pulse_cadence_warning_days int default 7`
- `pulse_cadence_critical_days int default 21`
- `pulse_cadence_terminal_days int default 45`
No new tables. The pulse signal computation is read-time from existing
data (`documents`, `invoices`, `reservations`, `interest_berths`) — no
persistent signal log needed.
### API additions
- `GET /api/v1/admin/pulse/settings` — read current config.
- `PUT /api/v1/admin/pulse/settings` — write config (Zod-validated).
Existing pulse computation in `src/lib/services/deal-pulse.service.ts`:
- Extend `computePulseFor(interestId)` to read per-port settings + new
signal sources.
- Cache settings per-port for the request lifetime.
### UI
- `<DealPulseChip>` already exists; extend label resolution to use
per-port custom labels with fallback to built-ins.
- New admin page (1 file, ~250 LOC).
### Acceptance
- Each signal fires when the linked event happens (verified via
integration test triggering the upstream event).
- Master toggle off → chip absent on every surface.
- Per-signal toggle off → signal absent from chip even when event
fires.
- Custom label "Hot" → "Active" renders correctly.
- Cadence threshold 7 → 14 → 30 → tier transitions match the new
thresholds.
### Test plan
- Unit per signal: trigger upstream event, assert pulse output contains
the signal.
- Unit cadence tier: insert interests with stage-entered timestamps
at boundary ages, assert tier classification.
- Integration: admin page round-trips config save + read.
### Effort
~56h. One PR.
---
## Phase 3 — EOI field overrides
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §1 (base spec) + user
clarifications below.
### Goal
When generating an EOI, rep can override pre-filled fields (contact
info, addresses, yacht name + dimensions) from a dropdown of every
known value for that channel. Manual entries persist as tracked
secondary values; future EOIs can pick them from the dropdown. Yacht
overrides spawn a new yacht record linked to the same interest/client.
### User clarifications captured
1. **Multi-value contacts:** Email and phone fields render as
dropdowns of every `client_contacts` row for that channel.
2. **Per-EOI vs persistent override:**
- "Use only for this EOI" → write to `documents.override_*` cols,
don't touch `client_contacts`.
- "Save as new contact" → insert `client_contacts` row with
`is_primary=false`, `source='eoi-custom-input'`.
- "Set as default for future documents" → promote to
`is_primary=true`, demote the prior primary.
3. **Badge label:** Use `[EOI]` not `[EOI Only]` (future docs may
reuse the value).
4. **Yacht spawn:** EOI form's yacht-name field has a "+ New yacht"
button → inline modal opens with the existing `<YachtForm>` —
on save, new yacht linked to same client/interest, tagged with
`yachts.source = 'eoi-generated'`. The current EOI uses the new
yacht. Original yacht untouched.
### Schema additions
- `client_contacts.source text default 'manual'` — values:
`'manual' | 'imported' | 'eoi-custom-input'`.
- `client_contacts.source_document_id text references documents(id) on delete set null`.
- `client_addresses.source` + `source_document_id` (mirror).
- `yachts.source text default 'manual'` — values:
`'manual' | 'imported' | 'eoi-generated'`.
- `yachts.source_document_id text references documents(id) on delete set null`.
- New audit_actions enum entries: `eoi_field_override`,
`promote_to_primary`, `eoi_spawn_yacht`.
- New `documents.override_*` columns (nullable):
`override_client_email`, `override_client_phone`, `override_client_address_line_*`,
etc. — per the field map in `eoi-documenso-field-mapping.md`.
### API additions
- `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary` —
promotes a non-primary contact, demotes the prior primary.
- `POST /api/v1/yachts` extension: accepts `source` + `source_document_id`
fields (admin-only or system-only).
- EOI generate endpoint
(`/api/v1/document-templates/[id]/generate-and-sign`) accepts
per-field override params; persists to `documents.override_*` cols
or spawns `client_contacts` rows depending on the toggle state.
### UI surface
- `<EoiGenerateDialog>` — each editable field becomes a `<Combobox>`
with the multi-value list + a "Save as new …" inline action.
- Below each field: two checkboxes:
- `[ ] Use only for this EOI` (default off)
- `[ ] Set as default for future docs` (default off)
- Client + Yacht detail pages: `[EOI]` badge on non-primary
rows; "Set as primary" action on each row.
- Yacht spawn: "+ New yacht" button next to yacht dropdown opens
Sheet (`<Sheet side="right">`, per CLAUDE.md doctrine) with the
existing `<YachtForm>`. On save, new yacht is preselected.
### Acceptance
- Multi-email client: EOI dropdown shows all emails; rep picks the
secondary; EOI uses it.
- "Save as new contact" creates a `client_contacts` row visible in
the client detail panel.
- "Set as default" promotes to primary and demotes the prior.
- Yacht spawn: new yacht visible under both client and interest
with the `[EOI]` badge; original yacht unchanged.
- Audit log records each override action with the source doc id.
### Test plan
- Unit per scenario: per-EOI override, save-as-new, promote-to-primary,
yacht spawn.
- Integration: full EOI generate flow with overrides, assert resulting
doc + side-effects.
- E2E (smoke): rep generates EOI with a custom email + new yacht;
artifacts visible on detail pages.
### Effort breakdown (across sessions)
- **Session 3a (~3 days):** Schema migration + audit_actions + API endpoints
for contact promote + document override persistence. Tests for service layer.
- **Session 3b (~3 days):** UI — EOI form dropdowns, "save as new"
inline flow, "set as default" toggle, badges on client/yacht detail.
- **Session 3c (~2 days):** Yacht spawn flow — Sheet + YachtForm
reuse + interest auto-link. Integration tests + E2E smoke.
- **Session 3d (~12 days):** Polish — audit log surfacing in
audit-log UI, badges/labels in notification copy, documentation.
### Risks
- Schema migration is FK-heavy. Run `pnpm db:generate` carefully;
the partial unique index on `client_contacts.is_primary` (one primary
per channel) must not be broken by the promote endpoint.
- The promote endpoint is a two-step write that must be transactional
(demote prior primary, then promote target) — wrap in
`db.transaction`.
### Effort
~11.5 weeks. Split into 4 sub-sessions per the breakdown above.
---
## Phase 4 — Reminders
**Reference:** `POST-AUDIT-SPEC-2026-05-18.md` §2.
### Goal
Reps can: (a) attach a follow-up note to interest cadences, (b) create
standalone tasks not tied to an entity, (c) assign tasks to other reps,
(d) configure their default firing time-of-day with per-row override.
### Schema additions
- `interests.reminder_note text NULL` — surfaced in notification body
and inbox row.
- `user_profiles.digest_time_of_day time NOT NULL DEFAULT '09:00'`.
- `reminders.fired_at timestamptz NULL` — drives worker idempotency.
- No new tables. `reminders` table already has title, note, priority,
due_at, assigned_to, snoozed_until, google_calendar_event_id.
### API additions
- `POST /api/v1/reminders` — extend to accept null `linked_entity` for
standalone tasks.
- `PATCH /api/v1/me/profile` — extend to accept `digest_time_of_day`.
- `GET /api/v1/reminders/inbox` — filter `[Mine | All my port]` toggle.
### UI surface
- New shared component `<CreateReminderDialog>` — Title (required),
Note (optional), Due date+time (defaults to user's TOD),
Priority dropdown, Assign-to picker (default = current user),
Linked entity dropdown (only visible from inbox surface; locked
on per-entity surface).
- `<RemindersInbox>`: `[+ New task]` button → dialog.
- Interest / client / berth / yacht detail pages: existing Reminders
section gains `[+ Task]` button → dialog (linked entity pre-filled).
- Settings page: time picker for "default reminder time".
### Worker scheduler
- 15-min cron tick scans `reminders WHERE due_at <= now() AND fired_at IS NULL`,
fires the notification, sets `fired_at`. Wrap in `pg_advisory_xact_lock`
per-port to avoid duplicate fires on parallel workers.
### Acceptance
- Standalone task: created from inbox, no linked entity, fires at
the chosen TOD.
- Per-interest cadence with note: surfaces in notification body +
inbox row.
- Assign to another rep: assignee sees task in their inbox; chip
shows the assignor's name; original creator sees an assignee chip.
- Default TOD set to 14:00 → new reminders default to 14:00; per-row
override to 09:30 wins.
- Worker idempotency: same reminder fires once even if 2 worker
processes race.
### Test plan
- Unit: cron scan picks up due reminders; fired_at gates re-firing.
- Integration: dialog → POST → DB row visible in inbox.
- E2E: rep creates standalone task from inbox; appears for assignee.
### Effort
~34 days. One PR.
---
## Phase 5 — §11.3 Email-copy refactor
**Reference:** old-CRM Nuxt repo at
`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/`
(notable: `server/utils/email.ts`, `server/tasks/process-sales-emails.ts`,
`components/EmailComposer.vue`).
### Goal
Modernize email tone to luxury-port voice; audit per-port branding
chain (logo, signature block, footer copy); ensure every automated
email uses the per-port branded shell.
### Scope in
- **Tone pass:** Rewrite every template in `src/lib/email/templates/`
using the old-CRM templates as voice reference (open them, capture
cadence + phrasing, port to current React-email or HTML-string
format).
- **Branding chain audit:** Walk every sender callsite (`sendEmail` in
`src/lib/email/`), confirm port-specific logo URL and footer get
threaded through via `cfg.portLogoUrl`, `cfg.portFooter`. Fix any
hard-coded `s3.portnimara.com/logo.png` strings (current templates
reference this directly per CLAUDE.md note).
- **New templates if missing:** signing-invitation cascade, supplemental
form, reminder digest. Match the existing tone after the rewrite.
### Scope out
- Localization. Current templates are EN-only; defer i18n to Phase C
unless a port specifically requests another language.
- New triggers. Same set of emails as today, better copy + branding.
### Data
None. Settings keys for branding already exist (`port_logo_url`,
`port_email_footer`).
### API
None.
### UI
None (admin email panel already exposes the settings keys).
### Acceptance
- Each template renders correctly for port-nimara AND a 2nd test port
with different logo + footer.
- Old-CRM reference quotes inline in PR description for traceability.
- No remaining hard-coded port branding strings (grep `portnimara.com`,
expect zero matches outside settings defaults).
### Test plan
- Snapshot tests per template at the rendering layer.
- Manual: send a test email per template to a real inbox, confirm
branding renders.
### Effort
~57 days. The grunt is the tone rewrite (each template needs
~3045 min of focused copywriting + review). One PR with the
template files; CI snapshot tests gate it.
---
## Phase 6 — M-EM03 IMAP bounce-to-interest linking
**Reference:** Phase 7 §14.9 in the original system spec.
### Goal
When an outbound sales email bounces (NDR returned via IMAP), match
the bounce to the originating `document_sends` row and surface a
warning on the linked interest's email tab.
### Scope in
- Bounce parser in `src/lib/email/bounce-parser.ts` — extract original
recipient + bounce reason from common NDR formats (Gmail, Outlook,
Postfix, Exchange).
- Cron job in `src/jobs/processors/imap-bounce-poller.ts` — polls
configured `IMAP_*` mailbox for new bounces, matches against
`document_sends.recipient_email + sent_at`, updates
`document_sends.bounce_status` + `bounce_reason`.
- UI surface on interest's Emails tab: red banner + reason inline
with the bounced send row.
- Notification: rep gets an in-CRM notification when one of their
sends bounces.
### Scope out
- Auto-resending to corrected addresses (manual rep action).
- Out-of-office detection (different signal; defer).
### Data
- `document_sends.bounce_status text NULL` — values: `'hard'`, `'soft'`, `'ooo'`, `null`.
- `document_sends.bounce_reason text NULL`.
- `document_sends.bounce_detected_at timestamptz NULL`.
### API
- No external routes. Internal cron only.
- Existing `GET /api/v1/interests/:id/emails` projection extends to
surface bounce fields.
### UI
- `<InterestEmailsTab>` row gets a red border + "Bounced: <reason>"
banner when `bounce_status IS NOT NULL`.
- Notification bell entry: "Email to X bounced — check the interest".
### Acceptance
- Send 1 real bounced email to a test IMAP mailbox; cron picks it up
within 15 min; UI shows the bounce; notification fires.
- Soft bounce (OOO) vs hard bounce surface differently.
### Test plan
- Unit: parser against fixture NDRs from each provider.
- Integration: cron + DB update path.
- Manual: real bounce round-trip in dev with `EMAIL_REDIRECT_TO` off.
### Effort
~35 days. Parser fixtures are the longest tail.
---
## Phase 7 — PDF template editor (Phases 1+2)
**Reference:** `berth-recommender-and-pdf-plan.md` (background +
infrastructure context).
### Goal
A web-based PDF template editor that lets admins:
- Phase 1: View an existing PDF template, click on a page region to
drop a merge-field marker, save the field map.
- Phase 2: Edit existing fields (move, resize, delete), upload a new
PDF (replacing the source), live-preview the AcroForm fill.
This replaces the current "edit the template PDF in Acrobat, re-upload"
workflow with an in-app editor.
### Phase 7.1 — Read + place (Phase 1, ~2 weeks)
**Scope in:**
- New admin page `/admin/templates/[id]/editor/page.tsx`.
- PDF viewer using `react-pdf` (already in deps for invoice rendering).
- Field marker overlay: click on a region → enter merge-field token
name → marker persists in `document_templates.field_map JSONB`.
- Token autocomplete from `VALID_MERGE_TOKENS`
(`src/lib/templates/merge-fields.ts`).
- Save endpoint: `PUT /api/v1/document-templates/:id/field-map`.
**Scope out:**
- Editing existing AcroForm fields (separate workflow).
- Multi-page navigation (Phase 1 = page 1 only).
- Conditional fields, signatures, repeating sections.
**Data:**
- `document_templates.field_map JSONB NULL` —
`Array<{ token: string, page: int, x: float, y: float, w: float, h: float }>`.
**API:**
- `PUT /api/v1/document-templates/:id/field-map`.
**UI:** Full editor page; uses Sheet for token picker side-panel.
**Acceptance:**
- Admin places 3 markers on a sample PDF; saves; reopens; markers
persist at the right coords.
- Generating a doc from the template fills the AcroForm at those
coords with the merge-field values.
**Effort:** ~2 weeks.
### Phase 7.2 — Edit + preview (Phase 2, ~12 weeks)
**Scope in:**
- Drag-to-move existing markers.
- Drag-corner-to-resize markers.
- Delete marker via right-click → "Remove field".
- Live preview pane (right side) showing the AcroForm fill with
sample data from a chosen interest.
- Multi-page navigation (page picker top-left).
- New-PDF upload: replace the source file while preserving the
field map (warn if coord ranges shift).
**Scope out:**
- Conditional fields / signatures (defer to Phase 3, unscoped).
**Data:** No new schema; reuses `field_map` JSONB.
**API:**
- `POST /api/v1/document-templates/:id/preview` — accepts an
interest ID, returns a presigned URL to a transient preview PDF.
**UI:** Editor extends — page picker, preview pane, drag handlers,
right-click context menu.
**Acceptance:**
- Drag a marker → save → reopen → marker is at the new coords.
- Resize marker → field rendering box matches new dims.
- Upload replacement PDF → field map preserved; warning shown if
page count changed.
- Live preview reflects current field map within 2s of edits.
**Effort:** ~12 weeks.
### Test plan (both phases)
- Unit: field-map serialization + coord persistence.
- Integration: PUT field-map → re-GET → exact roundtrip.
- E2E: admin places marker, generates doc, signed PDF has value at
expected coord (within tolerance).
### Risks
- `react-pdf` performance on large PDFs — measure on 50-page samples
before committing to the page-picker UX.
- Coord system: PDF uses bottom-left origin; viewer uses top-left.
Wrap a single coord-converter to avoid scattered conversions.
### Effort total
~34 weeks for both phases. Highest cost in the plan; queue last.
---
## Execution discipline
Each session that picks up a phase MUST:
1. **Read this doc + the referenced companion** before opening any
source file.
2. **Confirm the issue still exists** — re-grep the call sites listed
in the phase to ensure prior work hasn't already fixed something.
3. **Open a single PR per phase** unless explicitly split into
sub-sessions (Phase 3 is split into 3a/3b/3c/3d).
4. **Run all four quality gates** before reporting done:
`pnpm exec vitest run` · `pnpm tsc --noEmit` · `pnpm lint` ·
`pnpm build` (build only for changes touching middleware, env,
or build config).
5. **Update this doc** — mark the phase ☑ in the sequencing summary
table; capture any spec drift in a `## Implementation notes`
addendum at the end of the phase.
---
## Open questions deferred to phase-start
These don't block the plan but should be resolved when the relevant
phase starts:
- **Phase 1:** Phase 5's nginx config — does the ops repo own this
file, or does this CRM repo? (Resolve before Phase 1.3.)
- **Phase 2:** Should label rename support multi-language, or is
EN-only acceptable for the per-port admin? (Recommend EN-only;
i18n is Phase C work.)
- **Phase 3:** Should `[EOI]` badges fade after a TTL or persist
forever? (Recommend forever — rep chose this label deliberately.)
- **Phase 5:** When the old-CRM tone reference is opened, capture
35 representative templates and quote them in the PR description
for reviewer traceability.
- **Phase 7:** Confirm `react-pdf` performance budget on the largest
template currently in production (capture page-count + LCP).
---
## 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