From ee3cbb9b3901d11f96e4b7f681654acce6953614 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 18 May 2026 14:50:00 +0200 Subject: [PATCH] docs(plan): expand master plan with detailed implementation appendix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-phase appendices A–H with: - Per-file change lists for every phase - Schema migration SQL skeletons (Phases 2, 3, 4, 6, 7) - API request/response shapes (Phases 3, 4, 6, 7) - Component-level UI breakdowns - Sub-session day-budget breakdowns - Cross-phase risks + definition of done Appendix A flags Phase 1.1 + 1.2 as already-shipped — narrows remaining Phase 1 work to ~3-4h (1.3 copy audit + 1.4 supplemental form per-port URL). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/MASTER-PLAN-2026-05-18.md | 967 +++++++++++++++++++++++++++++++++ 1 file changed, 967 insertions(+) diff --git a/docs/MASTER-PLAN-2026-05-18.md b/docs/MASTER-PLAN-2026-05-18.md index 2e7d507c..5202e61f 100644 --- a/docs/MASTER-PLAN-2026-05-18.md +++ b/docs/MASTER-PLAN-2026-05-18.md @@ -834,3 +834,970 @@ phase starts: - ☐ Phase 7 — PDF template editor - ☐ 7.1 Read + place - ☐ 7.2 Edit + preview + +--- + +# Detailed Implementation Appendix + +This appendix expands every phase with per-file change lists, schema +migration SQL skeletons, API request/response shapes, and component +breakdowns. Anything ambiguous in the phase summaries above is resolved +here. Read this in conjunction with the phase header. + +--- + +## Appendix A — Phase 1 (Documenso completion + Supplemental form) + +### A.1 — Status of each sub-phase against existing code + +A grep + read pass at the time of writing this appendix confirmed: + +- **1.1 Project Director RBAC notification → already in code** + (`src/lib/services/documents.service.ts:1268-1300`). Registry keys + `documenso_developer_user_id` + `documenso_approver_user_id` exist + (`src/lib/settings/registry.ts:116, 162`). Admin UI renders them via + `` with the + `user-select` field type (`registry-driven-form.tsx:499-507`). + → **Verification only.** Smoke test by linking a CRM user on a port, + triggering a recipient-signed webhook for the matching role, and + asserting the linked user receives a `document_signing_your_turn` + notification in their inbox. +- **1.2 Cascading invite to next signer → already in code** + (`sendCascadingInviteForNextSigner` at `documents.service.ts:1220`). + → **Verification only.** Send a 3-signer sequential EOI, sign + recipient 1, assert recipient 2 receives a branded "your turn" + email within 30s. +- **1.3 Embedded signing copy + nginx CORS → partial.** Signing + invitation copy lives in `src/lib/email/templates/` — needs a + grep for the actual file path. nginx config: confirm if owned by + this repo or the ops repo. → **Implementation needed.** +- **1.4 Supplemental form per-port URL → not started.** Existing + service at `src/lib/services/supplemental-forms.service.ts` mints + tokens for the CRM-hosted `/supplemental/[token]` route. → **Full + implementation needed.** + +### A.2 — Supplemental form per-port: per-file change list + +1. **`src/lib/settings/registry.ts`** — Add a new entry: + + ```ts + { + key: 'supplemental_form_url', + section: 'email.general', // or new 'supplemental' section + label: 'Supplemental form URL (optional)', + description: + 'When set, supplemental-info emails link to this URL with ?token=… appended. Leave blank to use the built-in CRM form at /supplemental/.', + type: 'string', + scope: 'port', + placeholder: 'https://portnimara.com/supplemental', + }, + ``` + +2. **`src/lib/services/port-config.ts`** — Map the new key: + + ```ts + supplementalFormUrl: 'supplemental_form_url', + ``` + +3. **Email send-out call site** — Find via: + `grep -rn "supplemental" src/lib/email src/lib/services/sales-emails*` + The link assembly looks like: + + ```ts + const cfg = await getPortEmailConfig(portId); + const url = cfg.supplementalFormUrl + ? `${cfg.supplementalFormUrl}?token=${encodeURIComponent(raw)}` + : `${env.APP_URL}/supplemental/${raw}`; + ``` + +4. **Admin page** — Re-render via `` + (or new section). No JSX edit needed if the section key matches an + existing card. + +5. **Fallback route confirmation** — `src/app/(portal)/public/supplemental-info` + stays as-is. Adds copy "If you don't see your details, contact your rep." + +### A.3 — Test plan additions + +- **Vitest unit:** `supplemental-form-link.test.ts` — + `resolveSupplementalUrl(cfg, raw)` returns external URL when set, + CRM URL when blank. +- **Vitest integration:** `supplemental-email-send.test.ts` — mocks + a port with `supplemental_form_url` set; assert sent email body + contains the external URL. +- **Playwright (smoke):** admin can set + clear the URL; UI persists. + +### A.4 — Phase 1 effort revision + +Given 1.1 + 1.2 are already shipped, real remaining work is ~3–4h: + +- 1.3 signing-invitation copy audit: ~1h +- 1.3 nginx CORS: 5min if it's already documented, ~30min if not +- 1.4 supplemental form: ~2h +- Tests + smoke: ~30min + +--- + +## Appendix B — Phase 2 (Deal-pulse signals + admin config UI) + +### B.1 — Schema migration SQL + +```sql +-- 0072_pulse_admin_config.sql +-- All keys are stored in `system_settings` as JSON values with the +-- standard per-port scoping. No new columns or tables needed; the +-- registry-driven form handles serialization. + +-- No DDL — registry entries below seed the keys lazily on first read. +``` + +### B.2 — Registry entries to add + +In `src/lib/settings/registry.ts`: + +```ts +// ─── Deal Pulse ──────────────────────────────────────────────────── +{ key: 'pulse_enabled', section: 'pulse', label: 'Show deal pulse chips', + description: 'Master toggle. Off hides every pulse chip on every surface.', + type: 'boolean', scope: 'port', defaultValue: 'true' }, +{ key: 'pulse_signal_eoi_sent_enabled', section: 'pulse', + label: 'Signal: EOI sent', type: 'boolean', scope: 'port', defaultValue: 'true' }, +{ key: 'pulse_signal_deposit_received_enabled', /* ... */ }, +{ key: 'pulse_signal_contract_signed_enabled', /* ... */ }, +{ key: 'pulse_signal_document_declined_enabled', /* ... */ }, +{ key: 'pulse_signal_reservation_cancelled_enabled', /* ... */ }, +{ key: 'pulse_signal_berth_sold_to_other_enabled', /* ... */ }, +{ key: 'pulse_label_hot', section: 'pulse', + label: '"Hot" label override (default: Hot)', + description: 'Empty = use built-in label.', + type: 'string', scope: 'port' }, +{ key: 'pulse_label_quiet', /* default: "Quiet" */ }, +{ key: 'pulse_label_at_risk', /* default: "At Risk" */ }, +{ key: 'pulse_label_critical', /* default: "Critical" */ }, +{ key: 'pulse_label_eoi_sent', /* default: "EOI sent" */ }, +{ key: 'pulse_label_deposit_received', /* default: "Deposit paid" */ }, +{ key: 'pulse_label_contract_signed', /* default: "Contract signed" */ }, +{ key: 'pulse_label_document_declined', /* default: "Declined" */ }, +{ key: 'pulse_label_reservation_cancelled', /* default: "Reservation cancelled" */ }, +{ key: 'pulse_label_berth_sold_to_other', /* default: "Berth resold" */ }, +{ key: 'pulse_cadence_warning_days', section: 'pulse', + label: 'Warning threshold (days)', type: 'number', scope: 'port', + defaultValue: '7' }, +{ key: 'pulse_cadence_critical_days', /* default 21 */ }, +{ key: 'pulse_cadence_terminal_days', /* default 45 */ }, +``` + +### B.3 — Signal-firing hook sites + +| Signal | Hook file | Hook function | +| ----------------------- | --------------------------------------------- | ------------------------------------------------------------ | +| `eoi_sent` | `src/lib/services/documents.service.ts` | `sendDocument` / `markAsSent` | +| `deposit_received` | `src/lib/services/invoices.service.ts` | `markPaid` (filter `purpose='deposit'`) | +| `contract_signed` | `src/lib/services/documents.service.ts` | `handleDocumentCompleted` (filter `templateType='contract'`) | +| `document_declined` | `src/lib/services/documents.service.ts` | `handleDocumentRejected` | +| `reservation_cancelled` | `src/lib/services/reservations.service.ts` | `cancelReservation` | +| `berth_sold_to_other` | `src/lib/services/interest-berths.service.ts` | `upsertInterestBerth` when conflict detected | + +Each hook fires the signal by emitting a row into a new lightweight +table OR by recording a timestamp on the interest. Recommend the +timestamp pattern (no new table): + +```sql +ALTER TABLE interests + ADD COLUMN pulse_last_eoi_sent_at timestamptz, + ADD COLUMN pulse_last_deposit_received_at timestamptz, + ADD COLUMN pulse_last_contract_signed_at timestamptz, + ADD COLUMN pulse_last_document_declined_at timestamptz, + ADD COLUMN pulse_last_reservation_cancelled_at timestamptz, + ADD COLUMN pulse_last_berth_sold_to_other_at timestamptz; +``` + +The pulse compute function then reads these columns + the per-port +admin config to assemble the chip output. + +### B.4 — Pulse compute function refactor + +`src/lib/services/deal-pulse.service.ts:computePulseFor(interestId)`: + +```ts +export interface PulseResult { + visible: boolean; // false if master toggle off + tier: 'neutral' | 'hot' | 'quiet' | 'at_risk' | 'critical'; + tierLabel: string; // resolved from per-port label override or default + signals: Array<{ + kind: 'eoi_sent' | 'deposit_received' | /* ... */; + label: string; // resolved + at: Date; + }>; +} +``` + +The function: + +1. Reads `pulse_enabled` → returns `{ visible: false }` early if off. +2. Reads per-signal toggles + label overrides into a memoized config. +3. Reads cadence-tier thresholds. +4. Computes tier from `stage_entered_at` against thresholds. +5. Builds the signals array — most-recent first, filtered by toggle + state. + +### B.5 — Admin page + +New file `src/app/(dashboard)/[portSlug]/admin/pulse/page.tsx`: + +```tsx +export default function PulseSettingsPage() { + return ( +
+ + +
+ ); +} +``` + +Add a link entry in `src/components/admin/admin-sections-browser.tsx`. + +### B.6 — UI usage + +`` already exists. Extend it to: + +1. Accept the full `PulseResult` (not just the tier). +2. Hide entirely when `visible: false`. +3. Render signal chips on hover/expand with their resolved labels. + +### B.7 — Test plan + +- **Unit per signal firing:** Insert an interest, trigger the upstream + event, assert the `pulse_last__at` column updated. +- **Unit per signal toggling:** With master toggle off → `computePulseFor` + returns `{ visible: false }`. With per-signal toggle off → signal + absent from `signals[]`. +- **Unit per cadence:** Interest with `stage_entered_at` at boundaries + (6d, 7d, 21d, 22d, 45d, 46d) — tier transitions match. +- **Integration:** Admin page round-trips config save + read; chip + reflects changes within the request lifetime cache window. + +--- + +## Appendix C — Phase 3 (EOI field overrides) — comprehensive + +### C.1 — Decision rationale (locked from user input) + +1. Contact-channel dropdowns show every `client_contacts` row for that + channel, defaulting to the row with `is_primary=true`. +2. Override behaviours, controlled by two checkboxes below each field: + - Neither ticked → write to `documents.override_` only. + - "Use only for this EOI" ticked → same as above (explicit). + - "Save as new contact" → insert `client_contacts` row, + `is_primary=false`, `source='eoi-custom-input'`. + - "Set as default for future docs" → above + promote new row to + `is_primary=true`, demote prior primary inside one transaction. +3. Badge label: `[EOI]` (not `[EOI Only]`). +4. Yacht overrides: spawn new yacht via inline Sheet + ``. + New yacht tagged `yachts.source='eoi-generated'` and + `yachts.source_document_id=`. Original yacht untouched. +5. Audit trail: every action emits `audit_log` row with action + `eoi_field_override`, `promote_to_primary`, or `eoi_spawn_yacht`. + +### C.2 — Schema migration SQL + +```sql +-- 0073_eoi_overrides.sql + +-- Track origin of contacts so non-primary rows surface as "[EOI]" +-- and so we can reverse-link them to the generating document. +ALTER TABLE client_contacts + ADD COLUMN source text NOT NULL DEFAULT 'manual', + ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL, + ADD CONSTRAINT chk_client_contacts_source + CHECK (source IN ('manual', 'imported', 'eoi-custom-input')); + +-- Same pattern for addresses. +ALTER TABLE client_addresses + ADD COLUMN source text NOT NULL DEFAULT 'manual', + ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL, + ADD CONSTRAINT chk_client_addresses_source + CHECK (source IN ('manual', 'imported', 'eoi-custom-input')); + +-- Yacht origin tracking. +ALTER TABLE yachts + ADD COLUMN source text NOT NULL DEFAULT 'manual', + ADD COLUMN source_document_id text REFERENCES documents(id) ON DELETE SET NULL, + ADD CONSTRAINT chk_yachts_source + CHECK (source IN ('manual', 'imported', 'eoi-generated')); + +-- Per-document overrides — stored on the document itself, separate +-- from the canonical client/yacht records. The full field set mirrors +-- VALID_MERGE_TOKENS from src/lib/templates/merge-fields.ts. +ALTER TABLE documents + ADD COLUMN override_client_email text, + ADD COLUMN override_client_phone text, + ADD COLUMN override_client_address_line_1 text, + ADD COLUMN override_client_address_line_2 text, + ADD COLUMN override_client_city text, + ADD COLUMN override_client_state text, + ADD COLUMN override_client_postal_code text, + ADD COLUMN override_client_country text, + ADD COLUMN override_yacht_name text, + ADD COLUMN override_yacht_length_ft numeric(10,2), + ADD COLUMN override_yacht_width_ft numeric(10,2), + ADD COLUMN override_yacht_draft_ft numeric(10,2); + +-- Audit-actions enum gains 3 new verbs. Drizzle treats these as +-- string union — update the enum definition + run the seed audit. +ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_field_override'; +ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'promote_to_primary'; +ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'eoi_spawn_yacht'; +``` + +### C.3 — Drizzle schema updates + +`src/lib/db/schema/clients.ts`: + +```ts +export const clientContacts = pgTable('client_contacts', { + // existing columns... + source: text('source').notNull().default('manual'), + sourceDocumentId: text('source_document_id').references(() => documents.id, { + onDelete: 'set null', + }), +}); +``` + +Mirror in `client_addresses` and `yachts.ts`. Add the override columns +to `documents.ts`. Drop in `EoiOverrideValuesSchema` zod type at +`src/lib/validators/documents.ts`. + +### C.4 — API endpoints + +#### C.4.1 Promote contact to primary + +`POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary` + +Request: empty body. + +Response: `{ data: { promoted: , demoted: | null } }`. + +Implementation: wrap demote + promote in a transaction. Reject if the +target is already primary. Emits `audit_log` with action +`promote_to_primary`, metadata `{ channel, prior_primary_id }`. + +#### C.4.2 Promote address to primary + +`POST /api/v1/clients/[id]/addresses/[addressId]/promote-to-primary` — +mirror of C.4.1. + +#### C.4.3 Generate EOI with overrides + +Existing route `POST /api/v1/document-templates/[id]/generate-and-sign` +extends its request schema: + +```ts +export const generateAndSignSchema = z.object({ + interestId: z.string(), + pathway: z.enum(['documenso', 'in-app']), + overrides: z + .object({ + values: z.record(z.string(), z.string()).optional(), + // For each overridden field, the rep can pick: + // - 'document-only': write to documents.override_ + // - 'save-secondary': insert client_contacts/addresses row, not promoted + // - 'save-primary': insert + promote (demotes prior primary) + persistence: z + .record(z.string(), z.enum(['document-only', 'save-secondary', 'save-primary'])) + .optional(), + // Yacht-spawn signal: rep clicked "+ New yacht" inline. + // The yacht is created via POST /api/v1/yachts before this call + // and its id passed through here. + spawnedYachtId: z.string().optional(), + }) + .optional(), +}); +``` + +Service layer applies persistence per field inside one transaction so +a downstream error rolls back contact/address inserts. + +#### C.4.4 Yacht create from EOI + +`POST /api/v1/yachts` accepts new optional fields: + +```ts +{ + // existing required: name, ownerType, ownerId, ... + source: 'eoi-generated' | 'manual', + sourceDocumentId?: string | null, // populated when source === 'eoi-generated' + interestId?: string, // when set, auto-link as interest's yacht +} +``` + +### C.5 — UI surface — per file + +#### C.5.1 `` (or rename to Sheet per CLAUDE.md) + +File: `src/components/documents/eoi-generate-dialog.tsx` (existing). + +Per field (email, phone, address, yacht): + +1. Replace `` with `` populated from multi-value rows. +2. Below: 2 checkboxes: + - `[ ] Use only for this EOI` + - `[ ] Set as default for future docs` +3. For yacht field: append "+ New yacht" button next to the dropdown + that opens an inline Sheet (``) wrapping the + existing ``. On save, new yacht is preselected. + +#### C.5.2 Client detail panel — contacts list + +File: `src/components/clients/client-form.tsx` (or wherever contacts list). + +- Add `[EOI]` chip on rows where `source === 'eoi-custom-input'`. +- Add "Set as primary" inline action on non-primary rows; calls + C.4.1. + +#### C.5.3 Yacht detail panel + +File: `src/components/yachts/yacht-form.tsx` (or detail page). + +- Show `[EOI]` chip when `yacht.source === 'eoi-generated'`. +- Link "Generated from EOI: " pointing at + `/documents/` when present. + +### C.6 — Sub-session breakdown (5 sub-sessions) + +- **3a — Schema + service + APIs (3 days):** Migration, Drizzle + schema, promote-to-primary endpoints, generate-and-sign extension, + yacht-create extension. Unit tests for each service function. +- **3b — EOI dialog UI (3 days):** Combobox + checkboxes + persistence + call. Vitest component snapshots. +- **3c — Yacht spawn (2 days):** Inline Sheet + YachtForm reuse + + preselect. E2E smoke for full flow. +- **3d — Client/yacht detail surfacing (1 day):** Badges, set-primary + actions, source-doc link. +- **3e — Audit + docs (1 day):** Audit-log entries surfacing in + `/admin/audit`, audit-action filter chips, README + CLAUDE.md + updates. + +### C.7 — Open implementation questions (Phase 3 only) + +- Should `documents.override_*` columns be archived to a JSONB blob + instead? Recommend NO — typed columns are query-friendly for the + promote-to-primary "where used" view in admin. +- Should the EOI dialog warn the rep when their pick is already the + primary? Recommend YES — small UX nicety, prevents accidental + duplicate rows. +- Yacht spawn from EOI: should the new yacht inherit the interest's + current yacht's berth links? Recommend NO — yachts are independent; + rep can copy manually. + +--- + +## Appendix D — Phase 4 (Reminders) — comprehensive + +### D.1 — Schema migration SQL + +```sql +-- 0074_reminders_expansion.sql + +ALTER TABLE interests + ADD COLUMN reminder_note text; + +ALTER TABLE user_profiles + ADD COLUMN digest_time_of_day time NOT NULL DEFAULT '09:00'; + +ALTER TABLE reminders + ADD COLUMN fired_at timestamptz; + +-- Worker idempotency: ensure two parallel workers can't double-fire. +CREATE UNIQUE INDEX uniq_reminders_fired_once + ON reminders (id) + WHERE fired_at IS NOT NULL; +-- (logically unique by PK anyway, but the index serves as a self- +-- documenting fingerprint for the worker's "did I already fire?" check.) +``` + +### D.2 — Service additions + +`src/lib/services/reminders.service.ts` (existing — extend): + +```ts +export async function createReminder(input: { + portId: string; + userId: string; + assigneeId?: string; // defaults to userId + title: string; + note?: string; + priority?: 'low' | 'medium' | 'high'; + dueAt: Date; + linkedEntityType?: 'interest' | 'client' | 'berth' | 'yacht' | null; + linkedEntityId?: string | null; +}): Promise { + /* ... */ +} + +export async function listReminderInbox(input: { + portId: string; + userId: string; + filter: 'mine' | 'all_port'; +}): Promise { + /* ... */ +} +``` + +### D.3 — Worker scheduler refactor + +New file `src/jobs/processors/reminder-firing.ts`: + +```ts +// Runs every 15 minutes via the BullMQ scheduler. +export async function fireReadyReminders(): Promise { + // Per-port advisory lock to prevent two workers double-firing. + for (const port of await listPortIds()) { + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${hashPortToBigint(port)})`); + const due = await tx + .select() + .from(reminders) + .where( + and( + eq(reminders.portId, port), + lte(reminders.dueAt, new Date()), + isNull(reminders.firedAt), + ), + ); + for (const r of due) { + await fireOne(r, tx); + await tx.update(reminders).set({ firedAt: new Date() }).where(eq(reminders.id, r.id)); + } + }); + } +} +``` + +### D.4 — UI: shared dialog component + +New file `src/components/reminders/create-reminder-dialog.tsx`: + +Fields: Title (required), Note (optional, textarea), Due date+time +(defaults to today + user's `digest_time_of_day`), Priority dropdown, +Assignee combobox (port users via `/api/v1/admin/users/picker`), +Linked entity dropdown (hidden when pre-filled). + +### D.5 — Mount points + +1. `src/components/reminders/reminders-inbox.tsx`: `[+ New task]` + button in toolbar. +2. `src/components/interests/interest-detail-header.tsx`: `[+ Task]` + button next to existing Reminders panel. +3. Mirror for clients/berths/yachts detail pages. + +### D.6 — Settings page + +`src/app/(dashboard)/[portSlug]/settings/notifications/page.tsx` +(or wherever user-level prefs live): add a time picker bound to +`user_profiles.digest_time_of_day` via PATCH `/api/v1/me/profile`. + +### D.7 — Sub-session breakdown + +- **4a — Schema + service + worker (1.5 days)** +- **4b — Dialog component + 4 mount points (1.5 days)** +- **4c — Settings page time-of-day picker + tests (0.5 days)** +- **4d — Integration + E2E (0.5 days)** + +--- + +## Appendix E — Phase 5 (Email-copy refactor) — comprehensive + +### E.1 — Old-CRM reference location (captured) + +`/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/` + +Notable files: + +- `server/utils/email.ts` — Nodemailer wrapper with subject/html shape. +- `server/tasks/process-sales-emails.ts` — automated send-out cadence. +- `components/EmailComposer.vue` — UI tone reference. +- `components/EmailCommunication.vue` — body markdown handling. + +**Step 1 of execution:** open these three files, capture 3–5 representative +template strings, quote in PR description for reviewer traceability. + +### E.2 — Templates to refactor (per-file) + +Current `src/lib/email/templates/`: + +- `portal-auth.ts` — activation + reset (already branded, voice pass needed) +- `signing-invitation.ts` — voice-pass + role-specific copy completeness check +- `signing-completion.ts` — voice-pass +- `supplemental-info-request.ts` — voice-pass + link to per-port URL + (depends on Phase 1.4) +- `reminder-digest.ts` — voice-pass; ties into Phase 4 (reminders) +- `bounce-warning.ts` — voice-pass; depends on Phase 6 (bounce linking) +- `port-invitation.ts` (CRM invite) — voice-pass +- `change-email-confirmation.ts` — voice-pass + +### E.3 — Branding chain audit + +Grep `s3.portnimara.com` across `src/lib/email/templates/` — replace +hard-coded URLs with `cfg.portLogoUrl` / `cfg.portEmailFooter`. + +Confirm every `sendEmail` callsite threads `portId` through to +`getPortEmailConfig(portId)` (not the env-fallback shape). + +### E.4 — Tone guidance + +After reading the old-CRM templates, write a 1-page tone guide at +`docs/email-tone-guide.md` capturing: + +- Sentence cadence (concise, second-person, no marketing fluff). +- Salutation conventions ("Dear " vs "Hello "). +- Sign-off conventions (rep name + role + port name). +- Action-phrase tone ("you may sign here" vs "click to sign"). + +Reviewer uses the guide to verify each refactored template. + +### E.5 — Test plan + +- **Snapshot per template:** `pnpm exec vitest run src/lib/email/templates/**.test.ts` + asserts each template renders for port-nimara and a 2nd test port + with different logo + footer. +- **Manual test send:** seed 8 representative scenarios; send each + to a test inbox (real or `EMAIL_REDIRECT_TO`); manually verify the + output reads in tone. + +### E.6 — Sub-session breakdown + +- **5a — Reference capture (0.5 days):** Open old-CRM, capture tone + guide, write `docs/email-tone-guide.md`. +- **5b — Branding chain audit (0.5 days):** Grep hard-coded URLs; + fix every call to thread port-specific values. +- **5c — Tone pass batch 1 (1.5 days):** portal-auth, signing-\*, + port-invitation, change-email-confirmation. +- **5d — Tone pass batch 2 (1.5 days):** supplemental-info, + reminder-digest, bounce-warning (waits on Phase 4 + 6 if needed). +- **5e — Snapshot tests + manual sends (1 day).** + +--- + +## Appendix F — Phase 6 (IMAP bounce-to-interest linking) — comprehensive + +### F.1 — Schema migration SQL + +```sql +-- 0075_bounce_tracking.sql +ALTER TABLE document_sends + ADD COLUMN bounce_status text, + ADD COLUMN bounce_reason text, + ADD COLUMN bounce_detected_at timestamptz, + ADD CONSTRAINT chk_document_sends_bounce_status + CHECK (bounce_status IS NULL OR bounce_status IN ('hard', 'soft', 'ooo')); + +CREATE INDEX idx_document_sends_bounce_status + ON document_sends (port_id, bounce_status) + WHERE bounce_status IS NOT NULL; +``` + +### F.2 — Parser fixtures (the long tail) + +`tests/fixtures/bounces/`: + +- `gmail-hard.eml` — Gmail user not found. +- `gmail-quota.eml` — Mailbox full (soft bounce). +- `outlook-hard.eml` — Recipient does not exist. +- `outlook-ooo.eml` — Out-of-office auto-reply. +- `postfix-permanent.eml` — Postfix 550. +- `postfix-temporary.eml` — Postfix 451. +- `exchange-quarantine.eml` — Quarantined. +- `gmail-blocked.eml` — Anti-spam block (hard). + +Parser must extract: original-recipient address, bounce class +(hard/soft/ooo), reason string, in-reply-to header. + +### F.3 — Parser API + +New file `src/lib/email/bounce-parser.ts`: + +```ts +export interface ParsedBounce { + originalRecipient: string | null; + bounceClass: 'hard' | 'soft' | 'ooo' | 'unknown'; + reason: string; + inReplyTo: string | null; +} + +export function parseBounce(raw: string | Buffer): ParsedBounce { + /* ... */ +} +``` + +Implementation uses `mailparser` (already in deps) for MIME parsing, +then a switch on `Content-Type` (multipart/report) vs subject-heuristics. + +### F.4 — Cron worker + +New file `src/jobs/processors/imap-bounce-poller.ts`: + +```ts +export async function pollBounces(): Promise { + for (const port of await listPortsWithImap()) { + const cfg = await getPortImapConfig(port.id); + const client = imapflow(cfg); + await client.connect(); + const lock = await client.getMailboxLock('INBOX'); + try { + const messages = client.fetch({ since: oneHourAgo() }, { source: true }); + for await (const msg of messages) { + const parsed = parseBounce(msg.source); + if (parsed.originalRecipient) { + await matchAndUpdateDocumentSend(port.id, parsed); + } + } + } finally { + lock.release(); + await client.logout(); + } + } +} +``` + +### F.5 — Matching algorithm + +`matchAndUpdateDocumentSend(portId, parsed)`: + +1. Find `document_sends` row where + `recipient_email = parsed.originalRecipient AND sent_at > now() - interval '7 days' AND bounce_status IS NULL`. +2. If found: update `bounce_status` + `bounce_reason` + + `bounce_detected_at`, fire notification to the sender (user_id from + document_sends.sent_by_user_id). +3. If not found: log + audit (the bounce may be for a stale send or a + non-CRM email). + +### F.6 — UI surface + +`src/components/interests/interest-emails-tab.tsx` (or wherever sends +render): red banner on rows where `bounce_status IS NOT NULL`. Banner +text: "Email bounced — ". + +Notification bell: new type `email_bounced` routed via existing +`createNotification` flow. + +### F.7 — Sub-session breakdown + +- **6a — Schema + parser + fixtures (2 days)** +- **6b — Cron worker + matching algorithm (1 day)** +- **6c — UI banner + notification + E2E (1 day)** +- **6d — Manual bounce round-trip test (0.5 days)** + +--- + +## Appendix G — Phase 7 (PDF template editor) — comprehensive + +### G.1 — Library choices + +- **PDF rendering:** `react-pdf` (already in deps). Limit to v7+ to + pick up the Canvas-free rendering path. +- **Coordinate system:** PDF native uses bottom-left origin; viewer + uses top-left. Wrap a single `coordTransformer` utility — never + scatter conversions. +- **Drag handles:** `react-draggable` (small footprint) for marker + movement. Resize via `react-resizable`. Both have stable types. + +### G.2 — Schema migration + +```sql +-- 0076_pdf_template_field_map.sql + +ALTER TABLE document_templates + ADD COLUMN field_map jsonb; + +COMMENT ON COLUMN document_templates.field_map IS + 'Array<{ token: string, page: int, x: float, y: float, w: float, h: float }> + Coords are percent of page width/height (0..1) so they survive page-size changes.'; +``` + +### G.3 — Editor page + +New file `src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx`: + +Layout (desktop): + +- Left: page picker (vertical thumbnails). +- Centre: PDF page render with overlay canvas for markers + drag handles. +- Right: field-map sidebar listing every marker with edit/delete actions. +- Bottom: "Add field" mode toggle + token autocomplete combobox. + +### G.4 — Field-map API + +`PUT /api/v1/document-templates/[id]/field-map`: + +Request: + +```ts +{ + fieldMap: Array<{ + token: string; // must be in VALID_MERGE_TOKENS + page: number; + x: number; // 0..1 + y: number; // 0..1 + w: number; // 0..1 + h: number; // 0..1 + }>; +} +``` + +Response: `{ data: { id, fieldMap, updatedAt } }`. + +Validation: + +- Each token must exist in `VALID_MERGE_TOKENS` (rejects typos at the + API boundary — same allow-list pattern as `createTemplateSchema`). +- `0 <= x < 1`, `0 <= y < 1`, `0 < w <= 1 - x`, `0 < h <= 1 - y`. +- `page >= 1`. +- Page count assertion: fetch the source PDF, count pages, reject if + any marker references a page beyond the count. + +### G.5 — Preview API + +`POST /api/v1/document-templates/[id]/preview`: + +Request: `{ interestId: string }`. + +Response: `{ data: { previewUrl: string } }` — signed URL (24h TTL) to +a transient PDF filled with the merge-field values pulled from the +specified interest's EoiContext. + +Implementation reuses `fillEoiForm` from +`src/lib/pdf/fill-eoi-form.ts` with a per-call coord-list override +from the in-memory edit state. + +### G.6 — Live preview wiring + +The editor's right pane: + +- Debounces edits at 500ms. +- POSTs to the preview endpoint. +- Renders the returned PDF inline via `react-pdf`. + +### G.7 — Multi-page navigation + +Page picker on the left scrolls the centre to the matching page + +keeps the field-map sidebar filtered to that page's markers. + +Edge case: a marker on page 3 of a 5-page template stays visible in +the sidebar but greys out when page 1 is shown — clicking it jumps to +page 3. + +### G.8 — New-PDF upload + +When admin uploads a replacement PDF: + +1. Compute MD5 of old + new PDFs — block upload if identical. +2. Compare page counts. If different, surface a warning modal with + the diff ("Existing template has 5 pages, new has 3. 2 fields on + pages 4+ will be removed."). +3. On confirm: replace source via the existing template-upload flow, + pruning out-of-range fields from `field_map`. + +### G.9 — Performance budget + +- Largest production template: ~12 pages, ~600KB. +- Editor LCP target: <2s on a 2017 MBP (worst common-case sales rep). +- `react-pdf` worker mode (loadPdfWithWorker) keeps the main thread + responsive during page rendering. +- Field-map state lives in a single `useReducer`; debounced + serialization avoids per-keystroke API hits. + +### G.10 — Sub-session breakdown + +- **7.1a — PDF render + page picker + read-only viewer (4 days):** No + field placement yet — just confirm `react-pdf` performs well on + production templates and the editor shell renders. +- **7.1b — Field placement (drop marker, save field-map, list) (5 days)** +- **7.1c — Field-map API + validation + tests (3 days)** +- **7.2a — Drag-move + resize markers (3 days)** +- **7.2b — Preview pane + signed-URL serving (4 days)** +- **7.2c — New-PDF upload + diff warning (3 days)** +- **7.2d — Multi-page navigation + edge cases (2 days)** + +### G.11 — Open implementation questions + +- Should the editor support conditional field placement (e.g., + "yacht_name" only renders when yacht is set)? Defer to Phase 3. +- Should the editor surface AcroForm fields embedded in the source PDF + separately from CRM-managed markers? Recommend YES — the existing + `assets/eoi-template.pdf` AcroForm flow should keep working alongside + the new percent-coord marker flow. Need a UI toggle to switch view + modes. +- Multi-tenant: should each port have its own template editor URL, or + is templating port-scoped via system_settings? Templates are + port-scoped today, so the editor URL becomes + `/admin/templates/[id]/editor` with port resolution via the existing + port-context middleware. + +--- + +## Cross-phase risks + considerations + +1. **Schema migrations are FK-heavy across Phases 3, 4, 6, 7.** Run + `pnpm db:generate` after each, inspect the generated SQL by eye, + apply to dev DB, restart `next dev` (per CLAUDE.md pool-cache note). + +2. **Audit-action enum extensions need careful ordering.** Postgres + doesn't allow enum value re-ordering, so the audit display order + relies on a label map (`src/lib/audit-action-labels.ts`). Update + alongside each enum extension. + +3. **Per-port admin pages multiply.** After all phases ship, this + adds: `/admin/pulse`, `/admin/templates/[id]/editor`. Confirm the + `` index covers them. + +4. **Worker process additions.** Phase 4 (reminders) and Phase 6 + (bounces) both add cron-style jobs. Confirm `Dockerfile.worker` + wakes them up; capture metrics for monitoring. + +5. **CLAUDE.md updates.** Each phase that adds doctrine (e.g. EOI + override marker badge, deal-pulse signal types) should land a + matching CLAUDE.md addition in the same PR so the AI assistant + doesn't unlearn the new patterns. + +6. **PR sizing.** Each sub-session targets one or two coherent + commits. Avoid mega-PRs — the merge-conflict surface area on + Phase 7 in particular needs small, incremental PRs. + +--- + +## Appendix H — Already-shipped audit residuals (reference) + +For traceability, the items completed before this plan started: + +- Audit fix waves: 3/3 CRITICAL, 14/15 HIGH (1 N/A), 28+ MEDIUM, 6/8 + LOW (commits `4b5f85c`, `0f99f05`). +- Documenso v2 polish: envelope-ID sync, signing-progress redesign, + 20+ UX fixes. +- env→admin migration: 30+ registry vars, per-port encryption, + 5 admin pages converted. + +Master plan picks up after these. + +--- + +## Definition of done (cross-phase) + +A phase is considered shipped when: + +- All sub-sessions are ticked in the §"Phase ☑/☐ tracker" above. +- `pnpm exec vitest run` passes. +- `pnpm tsc --noEmit` passes. +- `pnpm lint` passes. +- For phases touching middleware/env/build config: `pnpm build` passes. +- For UI-facing phases: at least one smoke E2E spec is added (or an + existing spec extended) under `tests/e2e/smoke/`. +- CLAUDE.md updated with any new doctrine. +- This master plan is updated — phase marked ☑ with a one-line + outcome note inline. + - ☐ 7.2 Edit + preview