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

30 KiB
Raw Blame History

Master Implementation Plan — Post-Audit Remediation & Feature Expansion

Created: 2026-05-18 Status: Active — sequenced across multiple sessions Companion docs:

  • POST-AUDIT-SPEC-2026-05-18.md — original design decisions (this doc extends + supersedes)
  • AUDIT-FIX-WAVE-2026-05-18.md — what already shipped
  • deal-pulse-trigger-audit.md — call-site inventory for §1.2 signal expansion
  • eoi-documenso-field-mapping.md — token → AcroForm map for §1.3 EOI overrides
  • berth-recommender-and-pdf-plan.md — prior PDF infrastructure context

This is the single source of truth for everything outstanding. Each phase is self-contained: a fresh session can pick up any phase and ship it without re-reading the others. Phases are ordered by dependency + ship-size; bigger features can be split across sessions inside their own phase boundary.


Sequencing summary

# Phase Effort Depends on
1 Documenso completion (7 → 2 → 5) + Supplemental form per-port ~910h none
2 Deal-pulse signal expansion + admin config UI ~56h none
3 EOI field overrides (multi-value contacts, addresses, spawn-yacht-from-EOI) ~11.5 weeks none
4 Reminders (reminder_note + standalone tasks + per-user TOD) ~34 days none
5 §11.3 email-copy refactor (luxury-port tone + per-port branding chain audit) ~57 days requires old-CRM reference
6 M-EM03 IMAP bounce-to-interest linking ~35 days none
7 PDF template editor (Phases 1+2) ~34 weeks none

Total visible work: ~78 weeks of focused development at 1 phase at a time. Phases 1, 2 can ship back-to-back as quick wins; Phases 3, 4, 5, 6 are medium; Phase 7 is the long one.


Phase 1 — Documenso completion + Supplemental form per-port

Reference: POST-AUDIT-SPEC-2026-05-18.md §3 (Supplemental form) + §4 (Documenso 2/5/7). Bundled because both touch admin UIs under /admin/documenso/ and /admin/email/.

1.1 Documenso Phase 7 — Project Director RBAC (~1h)

Goal: When a Documenso event arrives for the developer or approver signer, also notify the linked CRM user in their inbox.

Scope in:

  • Add "Linked to CRM user" dropdowns to /admin/documenso/page.tsx for the existing developer_user_id and approver_user_id system_settings.
  • Auto-fill name/email when a user is selected (read via existing /api/v1/admin/users/picker).
  • Webhook handler additions in src/app/api/webhooks/documenso/route.ts: when an event matches the developer/approver, also emit a documenso:signed notification routed to the linked CRM user.

Scope out:

  • Permissions changes (using existing notification routing).
  • New audit*log actions (existing documenso_webhook*\* covers it).

Data: No schema change. system_settings.developer_user_id and approver_user_id already exist.

API: No new routes. Reuses /api/v1/admin/users/picker.

UI: Two new fields in the Documenso admin page (left column, below the existing developer name/email pair).

Acceptance:

  • Selecting a CRM user fills the name + email fields automatically.
  • Test webhook fires: linked user sees a notification in their inbox.
  • Unlink (select "None"): no notification fires.

Test plan:

  • Unit: webhook router resolves user_id → notification target.
  • E2E (smoke): admin can link/unlink users; UI updates persist.

1.2 Documenso Phase 2 — Webhook handler enhancement (~34h)

Goal: Sequential signing fires "your turn" emails to signer N+1 when signer N completes; on COMPLETED, distribute signed PDF to all CC emails; tighten idempotency.

Scope in:

  • Cascading invite: in handleDocumentSigned, look up the next pending recipient (next recipientId in order with signed_at IS NULL) and queue a sendSigningInvitation for that signer. Sequential mode only (check signing_order).
  • On-completion CC distribution: in handleDocumentCompleted, after the PDF is downloaded and saved to files, email each documents.completion_cc_emails row with the signed PDF as a download link (signed URL, 24h TTL).
  • Token-based matching: prefer signing_token over email for webhook → recipient resolution; falls back to email-only when token is absent.
  • Idempotency: composite unique constraint (documensoDocumentId, recipientEmail, eventType) on documentEvents; replaces the current body-hash dedup.

Scope out:

  • Parallel-mode invite flow (already covered by initial distribution).
  • Self-hosted PDF attachment (link-only — keeps emails light, see CLAUDE.md note on email_attach_threshold_mb).

Data:

  • Migration: drop body-hash unique index on documentEvents, add unique(documensoDocumentId, recipientEmail, eventType). Migration is reversible — the body-hash column stays.

API: No new routes. Internal webhook handler only.

UI: No change.

Acceptance:

  • Sequential 3-signer doc: signer 1 signs → signer 2 receives invite email; signer 2 signs → signer 3 receives invite; signer 3 signs → COMPLETED fires and CC list gets the signed PDF link.
  • Duplicate webhook retries are no-ops (composite key blocks insert).
  • Parallel-mode doc: no cascade (all signers got their invite at send).

Test plan:

  • Integration: mock 3-signer sequential webhook stream, assert email count + distribution.
  • Integration: COMPLETED webhook with CC list, assert link email per CC.
  • Unit: idempotency composite key rejects duplicates.

1.3 Documenso Phase 5 — Embedded signing URL verification (~12h)

Goal: Confirm the marketing site's /sign/<type>/<token> route handles every signer-role × documentType combo; tighten role-specific copy in invitation emails.

Scope in:

  • Audit signerMessages map in src/lib/email/templates/signing-invitation.ts — fill gaps for every (role, documentType) pair currently in production.
  • nginx CORS block: constrain Documenso webhook receiver origin (config-only, no code change).
  • Manual verification pass: walk through /sign/eoi/<token>, /sign/contract/<token>, /sign/reservation/<token> for each signer role (client / approver / developer). Document missing states in a quick checklist.

Scope out:

  • New embed surfaces (current routes are sufficient).
  • CSP changes (handled in src/proxy.ts already).

Data: None.

API: None.

UI: Copy-only changes in invitation email body.

Acceptance:

  • Each role × doc-type combo renders the correct welcome copy.
  • nginx config blocks unknown origins on the webhook receiver (verified by curl from a non-Documenso IP).

Test plan:

  • Snapshot tests on email template rendering for each (role, documentType) tuple.
  • Manual walkthrough checklist landed in PR description.

1.4 Supplemental form per-port setting (~2h)

Goal: "Send supplemental info form" link in auto-emails resolves to a marketing-site URL when configured per-port; falls back to the CRM-hosted /supplemental/[token] route otherwise.

Scope in:

  • New system_settings.supplemental_form_url key (per-port, optional, text). Schema already supports arbitrary keys.
  • Email link generator in src/lib/services/sales-emails.ts (or wherever the supplemental-info email is composed):
    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 mappulse_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: " 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 NULLArray<{ 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