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>
30 KiB
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 shippeddeal-pulse-trigger-audit.md— call-site inventory for §1.2 signal expansioneoi-documenso-field-mapping.md— token → AcroForm map for §1.3 EOI overridesberth-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.tsxfor the existingdeveloper_user_idandapprover_user_idsystem_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 adocumenso:signednotification 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 (nextrecipientIdin order withsigned_at IS NULL) and queue asendSigningInvitationfor that signer. Sequential mode only (checksigning_order). - On-completion CC distribution: in
handleDocumentCompleted, after the PDF is downloaded and saved to files, email eachdocuments.completion_cc_emailsrow with the signed PDF as a download link (signed URL, 24h TTL). - Token-based matching: prefer
signing_tokenover email for webhook → recipient resolution; falls back to email-only when token is absent. - Idempotency: composite unique constraint
(documensoDocumentId, recipientEmail, eventType)ondocumentEvents; 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, addunique(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
signerMessagesmap insrc/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.tsalready).
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_urlkey (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.tsxas 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 tosent. Already a call-site indocuments.service.tsfor stage auto-advance; hook apulseSignals.push({ kind: 'eoi_sent', at: now })next to it.deposit_received— fires when an invoice withpurpose = 'deposit'flips tostatus = 'paid'. Hook ininvoices.service.ts:markPaid.contract_signed— fires when adocumentsrow withtemplateType = 'contract'flips tostatus = 'completed'. Hook in webhook handlerhandleDocumentCompleted.
Negative (darken chip):
document_declined— fires when any doc on the interest flips tostatus IN ('declined', 'rejected'). Hook inhandleDocumentRejected(already exists from Phase A).reservation_cancelled— fires when areservationsrow flips tostatus = 'cancelled'. Hook inreservations.service.ts.berth_sold_to_other— fires when the interest's primary berth gets linked to a different completed interest. Hook ininterest-berths.service.ts:upsertInterestBerthwhen 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/):
- Master toggle (
pulse_enabled, defaulttrue): off → chip hides on every interest list/detail surface. - Per-signal toggles — checkbox per signal, all default on. Stored
as
pulse_signal_<name>_enabled. - 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. - Cadence threshold inputs — three numeric inputs for the day thresholds above.
- 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 truepulse_signal_<name>_enabled boolean default truefor each signalpulse_label_<key> textfor each renamable labelpulse_cadence_warning_days int default 7pulse_cadence_critical_days int default 21pulse_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
- Multi-value contacts: Email and phone fields render as
dropdowns of every
client_contactsrow for that channel. - Per-EOI vs persistent override:
- "Use only for this EOI" → write to
documents.override_*cols, don't touchclient_contacts. - "Save as new contact" → insert
client_contactsrow withis_primary=false,source='eoi-custom-input'. - "Set as default for future documents" → promote to
is_primary=true, demote the prior primary.
- "Use only for this EOI" → write to
- Badge label: Use
[EOI]not[EOI Only](future docs may reuse the value). - 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 withyachts.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 ineoi-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/yachtsextension: acceptssource+source_document_idfields (admin-only or system-only).- EOI generate endpoint
(
/api/v1/document-templates/[id]/generate-and-sign) accepts per-field override params; persists todocuments.override_*cols or spawnsclient_contactsrows 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_contactsrow 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:generatecarefully; the partial unique index onclient_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.
reminderstable already has title, note, priority, due_at, assigned_to, snoozed_until, google_calendar_event_id.
API additions
POST /api/v1/reminders— extend to accept nulllinked_entityfor standalone tasks.PATCH /api/v1/me/profile— extend to acceptdigest_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, setsfired_at. Wrap inpg_advisory_xact_lockper-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 (
sendEmailinsrc/lib/email/), confirm port-specific logo URL and footer get threaded through viacfg.portLogoUrl,cfg.portFooter. Fix any hard-codeds3.portnimara.com/logo.pngstrings (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 configuredIMAP_*mailbox for new bounces, matches againstdocument_sends.recipient_email + sent_at, updatesdocument_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/emailsprojection extends to surface bounce fields.
UI
<InterestEmailsTab>row gets a red border + "Bounced: " banner whenbounce_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_TOoff.
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-pdfperformance 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:
- Read this doc + the referenced companion before opening any source file.
- Confirm the issue still exists — re-grep the call sites listed in the phase to ensure prior work hasn't already fixed something.
- Open a single PR per phase unless explicitly split into sub-sessions (Phase 3 is split into 3a/3b/3c/3d).
- 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). - Update this doc — mark the phase ☑ in the sequencing summary
table; capture any spec drift in a
## Implementation notesaddendum 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-pdfperformance 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