NocoDB inspection (via MCP) confirms the legacy Interests table carries only the current Sales Process Level value plus point-in-time event timestamps as text fields — no dedicated stage-change history table. That isn't enough resolution to replay stages-over-time through the recommender's tier-ladder + heat-score weights. Simulator deferred until ~10+ real wins accumulate under the new pipeline, then we can simulate against actual CRM history. The existing /admin/berth-recommender heat-weight tuning UI is sufficient for v1 launch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
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 <ArchiveBerth> 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 inlineisActiveInterest)client-archive-dossier.service.ts:266-267client-restore.service.ts:189-190, 215client-archive.service.ts:214-215reminders.service.ts:424berths.service.ts:173-174(recommender feasibility check — verify semantics still match)interests.service.ts:1161-1162, 196, 361report-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_htmlsetting + admin field (~10 min) - Per-category send-from routing matrix (~3-4h)
- Bounce monitoring infrastructure (~6-8h):
email_bouncestable 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_bouncestable.- 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 — NocoDB inspection complete: simulator DEFERRED
NocoDB Interests carries only the current Sales Process Level
single-select + a handful of point-in-time event timestamps
(EOI Time Sent, Time LOI Sent, clientSignTime,
developerSignTime, EOI_Completed_At, finalized_document_sent_at)
scattered as text fields. There is no dedicated stage-change
history table — only the most recent stage value survives.
The recommender simulator's tier-ladder + heat-score logic depends on "how long did this deal sit at each stage" and "which stage did past deals make it furthest to before falling through." Without an advancement timeline that's not recoverable: every imported interest collapses to one data point.
Decision (2026-05-14): defer the simulator until production
accumulates ~10+ won deals under the new pipeline — then the simulator
can replay against real CRM history. The existing per-port heat-weight
tuning UI in /admin/berth-recommender is sufficient for v1 launch.
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, setCRM_INTAKE_SECRETon the website andWEBSITE_INTAKE_SECRETon the CRM, wire website's berth-map fetch + inquiry-submit + health probe perdocs/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/deleteshape, webhook payload (documentIdvsid),recipientIdvstoken. 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:
- Pull every row from legacy NocoDB via MCP.
- Audit messy MinIO storage; tie loose signed PDFs to client/interest/yacht where ownership is recoverable.
- Carry over historical Documenso documents (per-port API key + envelope IDs).
- Map legacy schema → current schema; fill obvious data gaps where the right answer is unambiguous.
- 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_SECRETviaopenssl 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(andsales@portnimara.comif 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:migrateagainst a fresh prod-shaped DB — runner ships in commit544b129; verify it actually runsCREATE INDEX CONCURRENTLYstatements.pnpm tsx scripts/migrate-storage.tsif switching from filesystem → s3 storage backend.- Verify
MULTI_NODE_DEPLOYMENT=trueis set if web + worker run on separate nodes (filesystem backend refuses to start otherwise). - Confirm
EMAIL_REDIRECT_TOis unset in production (src/lib/env.ts:110refuses to start otherwise). - Confirm
DOCUMENSO_API_URLis bare host (no/api/v1suffix) and matches the live Documenso version'sDOCUMENSO_API_VERSION. - Verify
/api/public/health?X-Intake-Secret=...returns 200 withchecks: { 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-zodadoption 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.