docs(plan): expand master plan with detailed implementation appendix

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:50:00 +02:00
parent c9debce442
commit ee3cbb9b39

View File

@@ -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
`<RegistryDrivenForm sections={['documenso.signers']}>` 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/<token>.',
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 `<RegistryDrivenForm sections={['email.general']} />`
(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 ~34h:
- 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 (
<div className="space-y-6">
<PageHeader title="Deal Pulse" description="…" />
<RegistryDrivenForm
sections={['pulse']}
title="Pulse chip behaviour"
description="Toggle the chip, rename labels per port, tune cadence thresholds."
/>
</div>
);
}
```
Add a link entry in `src/components/admin/admin-sections-browser.tsx`.
### B.6 — UI usage
`<DealPulseChip>` 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_<signal>_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_<field>` 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 + `<YachtForm>`.
New yacht tagged `yachts.source='eoi-generated'` and
`yachts.source_document_id=<doc-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: <ClientContact>, demoted: <ClientContact> | 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_<field>
// - '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 `<EoiGenerateDialog>` (or rename to Sheet per CLAUDE.md)
File: `src/components/documents/eoi-generate-dialog.tsx` (existing).
Per field (email, phone, address, yacht):
1. Replace `<Input>` with `<Combobox>` 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 (`<Sheet side="right">`) wrapping the
existing `<YachtForm>`. 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: <doc title>" pointing at
`/documents/<source_document_id>` 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<Reminder> {
/* ... */
}
export async function listReminderInbox(input: {
portId: string;
userId: string;
filter: 'mine' | 'all_port';
}): Promise<Reminder[]> {
/* ... */
}
```
### 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<void> {
// 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 35 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 <Name>" vs "Hello <First>").
- 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<void> {
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 — <reason>".
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
`<AdminSectionsBrowser>` 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