# Pre-deploy plan — locked 2026-05-14 Source of truth for everything between today and initial VPS deployment. Captures every decision reached in the 2026-05-14 planning session, plus the implementation order, deferred items, and operator checklist. If a future agent or session resumes this work, **start here** — do not re-litigate the decisions below without checking the transcript context that produced them. --- ## 1. Decisions ### 1.1 Hot-path correctness (numbers users see) | # | Item | Decision | File(s) impacted | | --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | 1 | Pipeline value mixed-currency | Convert each `berths.price` to the port-default currency at display time via `currency.service`, then sum. | `src/lib/services/dashboard.service.ts`, `src/components/dashboard/*` | | 2 | "Active interest" definition | `archivedAt IS NULL AND outcome IS NULL` (strictest). Won deals are CLOSED, not active. Extract single `activeInterestsWhere(portId)` SQL helper; route every site through it. | Sweep target — see § 2.1 for list. | | 3 | Occupancy source of truth | `berth.status = 'sold'`. KPI tile + revenue PDF + analytics timeline all derive from this one source. | `src/lib/services/dashboard.service.ts`, `src/lib/services/analytics.service.ts`, `src/lib/services/report-generators.ts` | | 4 | Revenue PDF shape | Two side-by-side cards on the same page: "Completed revenue (won, gross)" + "Forecast revenue (pipeline-weighted)". Stacks gracefully on portrait. | `src/lib/services/report-generators.ts` | | 4.5 | Multi-berth EOI mooring rendering | Populate the existing Documenso `Berth Number` form field with `eoiBerthRange` for both single- and multi-berth EOIs (single-berth output is identical to today via `formatBerthRange(['A1']) === 'A1'`). Drop the unused `Berth Range` payload key + AcroForm field + merge token. No Documenso admin action needed. | `src/lib/services/documenso-payload.ts`, `src/lib/pdf/fill-eoi-form.ts`, `src/lib/templates/merge-fields.ts`, `CLAUDE.md` | ### 1.2 Security / deploy gates | # | Item | Decision | | --- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 5 | Portal activation + password-reset token URLs | Switch `?token=ABC` → `#token=ABC` (URL fragment). Fragment never hits server logs, proxies, or `Referer` header. Touches email templates + `/portal/activate` + `/portal/reset-password` + the `set-password` page reader. | ### 1.3 Email infrastructure refactor | # | Item | Decision | | --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 6 | Admin "Signature HTML" field | **Delete** it. Currently writes `email_signature_html` to settings; `shell.ts` only reads `emailFooterHtml`. Footer covers brand sign-off; signatures are semantically per-user (separate future feature if asked). | | 7 | Per-category send-from routing | New admin matrix on `/admin/email`: each email category (account activation, password reset, notification digest, EOI signing request, brochure send, berth-PDF send, signed-doc completion, sales send-out, manual rep compose) gets a sender dropdown (`noreply` / `sales`). Sales option auto-disabled when sales SMTP/IMAP creds aren't set. | | 8 | Bounce monitoring | Per-port admin-configurable IMAP polling of one or more sender mailboxes. Parses DSN bounce notifications via `mailparser`. Writes to new `email_bounces` table, flags the original `document_send` / `notification` / `email_thread` message as bounced, and emits an in-app notification to the assigned sales rep when a _client_ email bounces. | | 9 | Attachment threshold compose UI | On the manual-compose dialog (brochure send, berth-PDF send, rep custom email), show a banner on any attached file above `email_attach_threshold_mb` that says "will be sent as a 24h signed-link download instead of inline attachment". Also audit current default threshold (10MB) against typical SMTP provider caps. | ### 1.4 Schema additions | # | Item | Decision | | --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 10 | `berths.archived_at` column | Add `archived_at` (timestamp, nullable) + partial index on `(port_id) WHERE archived_at IS NULL`. Filter `/api/public/berths` to exclude archived. Add `` action in berth detail header (soft-delete with audit log). | | 11 | `clients.metadata.source_inquiry_id` | Add field for inquiry → client linkage so the conversion funnel chart can attribute won deals back to the originating inquiry. | | 12 | `email_bounces` table | Bounce monitoring storage — see #8. Columns: `id`, `port_id`, `mailbox_address`, `bounced_address`, `original_send_type` (enum: `document_send` / `notification` / `email_thread`), `original_send_id`, `dsn_status`, `dsn_action`, `dsn_diagnostic`, `received_at`, `raw_message`. | | 13 | Bulk-berth UX | 2-step wizard for new-port setup. Step 1: pick dock letter + range + tenure (only genuinely-standard defaults). Step 2: editable table with "apply to selected" multi-row actions + Excel-style drag-fill on numeric columns. Step 3 from earlier rounds folded in. | ### 1.5 UX features | # | Item | Decision | | --- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 14 | "Mark as signed externally" action | On contract / reservation tabs: new action that records the document as signed without uploading a file. Captures optional reason in a warning modal. Advances pipeline + writes audit log. UI shows "⚠ No file on record — signed externally" indicator. Reps can later upload the file if they obtain a copy. | | 15 | Contract paper-upload endpoint | Clone the existing EOI `external-eoi` upload flow into `external-contract` and `external-reservation` endpoints. Mirrors the current EOI ergonomics. | | 16 | Inquiry P-4.5 wire-up | Make `/clients/new?prefill_*&inquiry_id=...` hydrate the create-client form from the searchParams **and** persist `inquiry_id` to `clients.metadata.source_inquiry_id`. Conversion funnel chart depends on this linkage. | | 17 | Quick brochure/PDF download | Add "Download" buttons on client detail header, interest detail header, berth detail header. Each downloads the current brochure (port-default) / berth PDF / signed contract from storage so the rep can attach to their own email or messenger app. | | 18 | Per-user reminder digest schedule | Build the simple version of `scheduler.ts:44` placeholder. User-settings dropdown for digest time + days-of-week. Falls back to port-default when unset. | | 19 | Documents tab N+1 batch fix | Replace the 4-call sequential walk in `listFilesAggregatedByEntity` (direct + company + yacht + client) with a single UNION query keyed by entity-relationship. Target: opening Documents tab on a busy client ≤500ms. | ### 1.6 Investor dashboard charts (toggleable widgets) Priority order. Each chart ships as a separate widget integrated into the existing widget-customization system; disabled by default for reps, enabled by default for admins. | # | Widget | Notes | | --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 20 | Total pipeline value of all berths | Single big number (port-default currency, conversion at display). Weekly-change sparkline below. Re-uses the #1 currency-conversion helper. | | 21 | Berth interest heatmap + ranked-table view | Heatmap shows pier-style grid colored by active-interest count per berth. Paired with a sortable ranked-table view of the same data — table is what exports cleanly to PDF/CSV. Both views toggleable. | | 22 | Pipeline velocity over time | Stacked area chart: count of interests in each pipeline stage, weekly. Investors see whether deals are advancing or stalling. | | 23 | Conversion funnel by lead source | Enquiry → qualified → EOI → contract → won, broken down by `lead_source`. Depends on #16 (inquiry → client linkage) for full attribution. | ### 1.7 Mechanical sweeps | # | Item | Decision | | --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 24 | "Deal" → "interest" terminology sweep | Full sweep. Updates: admin description copy (`/admin/qualification-criteria`, `/admin/documenso`), `bulk-archive-wizard.tsx` placeholders, `smart-archive-dialog.tsx`, `client-columns.tsx` comments, and the API route path `/api/v1/berths/[id]/deal-documents` → `/api/v1/berths/[id]/interest-documents`. Route rename includes caller updates + a 301 redirect on the old path for any external integrations. | --- ## 2. Implementation order Branch: **`main`** (feat/documents-folders has been fast-forwarded into main; new work continues on main directly). Test strategy: TDD-where-meaningful (services with behavioral changes — active-interest helper, currency converter, DSN parser). UI and mechanical sweeps covered by full vitest + tsc + lint + playwright smoke at the end. ### 2.1 Step 1 — Money math sweep (highest leverage) Extract `activeInterestsWhere(portId)` helper. Sweep these call sites: - `dashboard.service.ts` (already self-consistent, replace inline `isActiveInterest`) - `client-archive-dossier.service.ts:266-267` - `client-restore.service.ts:189-190, 215` - `client-archive.service.ts:214-215` - `reminders.service.ts:424` - `berths.service.ts:173-174` (recommender feasibility check — verify semantics still match) - `interests.service.ts:1161-1162, 196, 361` - `report-generators.ts:63, 85, 121` Then: - Pipeline value currency conversion (`dashboard.service.ts:35-47`) - Occupancy: switch analytics timeline to `berths.status = 'sold'` (`analytics.service.ts:195`) - Revenue PDF: two-card layout, weighted forecast + won-gross side-by-side (`report-generators.ts:109-150`) Estimated effort: ~half day. Single coherent commit set tagged `feat(reporting): canonical active-interest + occupancy + currency-aware pipeline value`. ### 2.2 Step 2 — Email infrastructure refactor - Drop `email_signature_html` setting + admin field (~10 min) - Per-category send-from routing matrix (~3-4h) - Bounce monitoring infrastructure (~6-8h): `email_bounces` table migration, IMAP poller worker, DSN parser, in-app notification on bounce, admin UI for sender configuration - Attachment threshold compose banner + threshold default audit (~1h) Estimated effort: ~1 day. Multi-commit. ### 2.3 Step 3 — Schema additions Single migration + service work: - `0065_pre_deploy_schema.sql`: `berths.archived_at`, `clients.metadata` (already JSONB — convention update only), `email_bounces` table. - Services + admin UI for archive berth + filter on public feed. Estimated effort: ~2h. ### 2.4 Step 4 — UX features - Externally-signed mark (contract + reservation tabs) + audit log + UI indicator - Contract + reservation paper-upload endpoints (clone EOI flow) - Inquiry P-4.5 wire-up (prefill form + persist inquiry_id) - Quick brochure/berth-PDF download buttons (3 surfaces) - Per-user reminder digest schedule - Documents tab N+1 batch query fix Estimated effort: ~1 day. Multi-commit. ### 2.5 Step 5 — Bulk-berth wizard Dedicated commit. New `/admin/berths/bulk-add` route + 2-step wizard component + smart-helpers (apply-to-selected, drag-fill). ~half day. ### 2.6 Step 6 — Investor dashboard charts Four toggleable widgets, each its own commit. ~1 day total. Depends on Step 1 (currency converter) and Step 3 (inquiry linkage). ### 2.7 Step 7 — Terminology sweep Mechanical. Run last to minimize merge churn. ~2h. ### 2.8 Step 8 — Portal token fragment switch Dedicated commit. Email template URL builder, page-side fragment readers, Better Auth integration test. ~1h. ### 2.9 Step 9 — Optional: NocoDB inspection + recommender simulator Pre-flight: use the NocoDB MCP to inspect what stage-advancement / win history exists in the legacy interest records. - **If recoverable**: build the "simulate against history" admin tool that replays past wins through current recommender weights. ~half day. - **If not**: defer until production accumulates ~10+ wins. Update `BACKLOG.md` to reflect. --- ## 3. Deferred items (will not block deploy) ### 3.1 External / operator actions (your side) - **Coordinate website cutover env vars**: generate shared secret with `openssl rand -hex 32`, set `CRM_INTAKE_SECRET` on the website and `WEBSITE_INTAKE_SECRET` on the CRM, wire website's berth-map fetch + inquiry-submit + health probe per `docs/website-cutover-runbook.md`. - **Legal review of right-to-be-forgotten scope** — anonymize vs true-delete decision. Mechanical fix once policy is set. - **Documenso v2 endpoint audit against live v2 instance** — verify `/api/v2/envelope/delete` shape, webhook payload (`documentId` vs `id`), `recipientId` vs `token`. Needs a live v2 instance. ### 3.2 Deferred indefinitely (no current trigger) - Bulk import queue worker (`src/lib/queue/workers/import.ts`) — superseded by bespoke migration scripts. Delete placeholder when the comprehensive NocoDB migration ships. - Auto-calibration of berth-recommender weights — depends on accumulating ≥10 won deals in the new system before it produces meaningful results. ### 3.3 Comprehensive NocoDB → CRM migration **Separate workstream** — its own multi-session project. Scope: 1. Pull every row from legacy NocoDB via MCP. 2. Audit messy MinIO storage; tie loose signed PDFs to client/interest/yacht where ownership is recoverable. 3. Carry over historical Documenso documents (per-port API key + envelope IDs). 4. Map legacy schema → current schema; fill obvious data gaps where the right answer is unambiguous. 5. Dry-run + apply against prod DB at initial startup. Not on the pre-deploy checklist below — handled as a dedicated planning session before the first port-data import. --- ## 4. Pre-deploy operator checklist In rough order. Tick as completed. ### 4.1 External (operator side) - [ ] Generate `WEBSITE_INTAKE_SECRET` via `openssl rand -hex 32`; configure both CRM and website to use it. - [ ] Coordinate website-cutover plan with website repo per `docs/website-cutover-runbook.md`. - [ ] Provision IMAP credentials for `noreply@portnimara.com` (and `sales@portnimara.com` if applicable) so bounce monitoring works at boot. - [ ] Provision SMTP credentials for both sender addresses; verify each can actually send. - [ ] DNS + SSL for the CRM domain. - [ ] Decide RTBF policy (anonymize vs true-delete) with legal; document in `docs/runbooks/`. ### 4.2 CRM side (run after code work is complete) - [ ] `pnpm exec vitest run` — all pass. - [ ] `pnpm exec tsc --noEmit` — clean. - [ ] `pnpm exec eslint .` — clean. - [ ] `pnpm exec playwright test --project=smoke` — passes. - [ ] `pnpm db:migrate` against a fresh prod-shaped DB — runner ships in commit `544b129`; verify it actually runs `CREATE INDEX CONCURRENTLY` statements. - [ ] `pnpm tsx scripts/migrate-storage.ts` if switching from filesystem → s3 storage backend. - [ ] Verify `MULTI_NODE_DEPLOYMENT=true` is set if web + worker run on separate nodes (filesystem backend refuses to start otherwise). - [ ] Confirm `EMAIL_REDIRECT_TO` is **unset** in production (`src/lib/env.ts:110` refuses to start otherwise). - [ ] Confirm `DOCUMENSO_API_URL` is bare host (no `/api/v1` suffix) and matches the live Documenso version's `DOCUMENSO_API_VERSION`. - [ ] Verify `/api/public/health?X-Intake-Secret=...` returns 200 with `checks: { db: 'ok', redis: 'ok' }`. --- ## 5. What's NOT in this plan Items explicitly out of scope for this deploy: - IMAP-based two-way email sync — feature scope decision, anti-automation stance. - AI features (semantic search, auto-summarize, anomaly detection) — anti-automation stance. - `.toLocale*` → `formatDate()` sweep (93 sites) — opportunistic as files are touched. - `drizzle-zod` adoption for the remaining ~28 validators — opportunistic. - Reports system + admin-composable report templates (`audit-followups Wave 11.C`) — post-deploy feature work. - Manual client form expansion (`Wave 11.A`) — post-deploy feature work. - Inquiry triage auto-classification (`Wave 11.F`) — post-deploy feature work. - Per-port email branding admin UI (`Wave 11.G`) — post-deploy feature work. --- _Last updated: 2026-05-14._