Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.
UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
Aggregated helpers in notes.service mirror the listFor*Aggregated
symmetric-reach joins. yacht-tabs + company-tabs render the
badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
`width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
fixed inset-0 pin so long forms scroll naturally). Form picks up
port branding (logoUrl + backgroundUrl + appName) via
loadByToken. Address fields completed (street + city + region +
postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
emits toast.success with action link to the destination entity
or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
(5 types visible at once).
Launch infra:
- UTM column wiring (Init 1b step 4): migration
0089_website_submissions_utm.sql adds utm_source/medium/campaign/
term/content + composite index (port_id, utm_source, received_at)
for per-campaign rollups. website-inquiries intake accepts the
five fields. Residential intake intentionally untouched per audit
scope.
- Invoicing module gate (Init 1c spike): new
invoices-module.service + invoices layout guard + registry entry
invoices_module_enabled (default false). Audit conclusion in
launch-readiness.md: payments table is canonical money path;
/invoices flow is parallel infrastructure now hidden by default.
Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
New route-labels.ts + use-smart-back hook +
navigation-history-tracker so back falls through to the parent
route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
breadcrumb-store kept for back-compat consumers but the
breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
upload-receipts, reports kind, tenancies detail, analytics
metric, client detail) migrated.
Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
the reports gap audit (cross-cutting filter set, Marketing +
Financial blockers, custom builder remaining entities, scheduled
CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
(each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Port Nimara CRM
Multi-tenant CRM for marina/port management. Next.js 15 App Router (standalone), React 19, TypeScript strict (noUncheckedIndexedAccess, no any), Drizzle ORM on PostgreSQL.
Quick reference
pnpm dev # Dev server
pnpm build # Production build
pnpm lint / format # ESLint / Prettier
pnpm db:generate # Generate Drizzle migrations
pnpm db:push # Push schema to DB
pnpm db:studio # Drizzle Studio GUI
pnpm db:seed # Seed (tsx src/lib/db/seed.ts)
# Tests
pnpm exec vitest run # Unit + integration (~3s)
pnpm exec playwright test --project=smoke # Click-through smoke (~10min)
pnpm exec playwright test --project=exhaustive # Full UI exhaustive
pnpm exec playwright test --project=destructive # Archive/delete flows
pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in)
pnpm exec playwright test --project=visual # Pixel-diff baselines
pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines
# Dev helpers
pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email
pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages
# Cloudflare quick-tunnel (for Documenso webhook testing)
launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start
launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop
./scripts/tunnel-url.sh --copy # print + copy webhook URL
# Schema migration (pnpm db:migrate is broken — apply via psql)
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -f src/lib/db/migrations/0075_*.sql
Working in this repo — skills, MCPs, agents
Reach for these before grinding through tasks manually:
- Skills (invoke with
Skilltool):superpowers:brainstormingbefore any feature/component work — explores intent + design firstsuperpowers:test-driven-developmentfor any feature or bugfixsuperpowers:systematic-debuggingfor any bug / test failure / unexpected behaviorsuperpowers:verification-before-completionbefore claiming "done" or committingsuperpowers:writing-plans/executing-plansfor multi-step specssuperpowers:dispatching-parallel-agentswhen 2+ tasks are independentfrontend-design:frontend-designfor new UI work (avoids generic AI aesthetics)code-review:code-reviewandsecurity-reviewbefore merging
- MCPs:
- Context7 (
mcp__plugin_context7_context7__*) — pull current docs for Next 15, Drizzle, better-auth, BullMQ, Tailwind, Radix etc. Prefer over web search; our training data lags. - Playwright (
mcp__plugin_playwright_playwright__*) — verify UI changes in a real browser before reporting "done". Default viewport — do NOT callbrowser_resize. - Serena (
mcp__plugin_serena_serena__*) — symbol-level navigation (find_symbol,find_referencing_symbols,replace_symbol_body). Much faster than grep for "where is this called". - Postman (
mcp__claude_ai_Postman__*) — when designing or auditing API surfaces.
- Context7 (
- Agents (via
Agenttool,subagent_type=):Explorefor any codebase search that would take > 3 queriesfeature-dev:code-explorer/code-architect/code-reviewerfor new feature work
- Doctrine: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins.
- Pre-launch tracker:
docs/launch-readiness.mdis the master pre-launch tracker for the beta phase. Append every launch-blocking initiative or sub-task there with status tags (OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED). Read it at the start of any non-trivial task. - Manual UAT — currently active doc:
docs/superpowers/audits/active-uat.mdis the live findings doc. Every UAT finding the user surfaces in chat lands here regardless of which session captures it. Persists across sessions until the user explicitly says to wrap the round and archive — at which point rename toYYYY-MM-DD-uat.mdand start a freshactive-uat.md. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged). Tag every entry with status:OPEN | IN PROGRESS | SHIPPED in <hash> | QUEUED | BLOCKED. Don't ask the format each time.
Tech stack (non-obvious choices)
- Auth: better-auth — session cookie
pn-crm.session_token - Queue: BullMQ + Redis (ioredis)
- Storage: pluggable via
getStorageBackend()— MinIO/S3 default; never import the S3 SDK directly - Realtime: Socket.IO with Redis adapter
- UI: Radix UI + shadcn/ui (
src/components/ui/) + Lucide + CVA + tailwind-merge - Forms: react-hook-form + zod resolvers
- State: Zustand (
src/stores/) + TanStack React Query - PDF: pdfme (templates) + pdf-lib (AcroForm fill)
- Email: nodemailer + imapflow + mailparser
Project structure
src/
app/
(auth)/ # Login/auth pages
(dashboard)/ # Main app — route: /[portSlug]/...
(portal)/ # Client portal
api/ # API routes (route.ts + sibling handlers.ts)
components/
ui/ # shadcn/ui base components
layout/ # Shell, sidebar, header
[domain]/ # clients, yachts, companies, reservations, berths, …
shared/ # Cross-domain (BrandedAuthShell, InlineEditableField, …)
hooks/ # use-auth, use-permissions, use-socket, …
lib/
api/ # Route helpers (parseBody, errorResponse, withAuth, …)
auth/ # better-auth config
db/schema/ # Drizzle schema — one file per domain, re-exported from index.ts
db/migrations/ # Generated Drizzle migrations (apply via psql in dev)
env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
services/ # Business logic
storage/ # Pluggable storage backend
templates/ # Email/document merge fields, berth-range formatter
validators/ # Zod schemas for API input
middleware.ts # Auth middleware (cookie check, redirects)
stores/ # Zustand
Conventions & gotchas
API shape
- Envelope:
{ data: <T> }for any returned content (read OR write). Mutations returning nothing emit204 No Content. Don't use{ success: true }(legacy; normalized away 2026-05-07). Public portal-auth endpoints keep{ success: true }so the frontend can chain. - Lists:
{ data: <T[]>, total?, hasMore? }— see/api/v1/clients. - Errors: always via
errorResponse(error)from@/lib/errors(request-id propagation + audit-tier mapping). - Body parsing: always
parseBody(req, schema)from@/lib/api/route-helpers. Rawreq.json() + schema.parse()produces a generic 500 instead of the field-level 400 the frontend'stoastErrorhook expects. - Route handlers:
route.tsfiles can only exportGET|POST|…. Service-tested handlers live in siblinghandlers.ts(e.g.src/app/api/v1/yachts/[id]/handlers.ts) and are imported byroute.tswithwithAuth(withPermission(...)). Integration tests import fromhandlers.tsdirectly to bypass middleware.
Data model
- Polymorphic ownership: Yachts and invoice billing-entities use
<entity>_type+<entity>_idpairs ('client' | 'company'). Resolve viasrc/lib/services/yachts.service.ts/eoi-context.ts— never read the columns ad hoc. - Multi-berth interest model:
interest_berthsis the source of truth —interests.berth_iddoes not exist (dropped in 0029). Three flags:is_primary(≤1 per interest, partial unique index — "the berth for this deal"),is_specific_interest(true → public map shows "Under Offer"),is_in_eoi_bundle(covered by EOI signature). Read/write only viasrc/lib/services/interest-berths.service.tshelpers. - Notes (polymorphic):
notes.service.tsdispatches acrossclientNotes/interestNotes/yachtNotes/companyNotesvia anentityTypediscriminator.<NotesList entityType="…" />works for all four.companyNoteslacksupdatedAt— service substitutescreatedAtfor shape uniformity. - Mooring number canonical format:
^[A-Z]+\d+$(e.g.A1,B12,E18) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, EOI-rendered in this exact form. Regex gates the public/api/public/berths/[mooringNumber]route before any DB hit. - Routes: Multi-tenant via
[portSlug]dynamic segment. Typed routes enabled.
Schema migrations during dev
After db:push or applying a migration via psql against a running dev server, restart next dev. Drizzle/postgres.js prepared statements cache stale column lists; symptom is 42703 column X does not exist 500s on migrated tables.
Documenso
- Webhooks: plaintext secret in
X-Documenso-Secret(no HMAC) — timing-safe equality viaverifyDocumensoSecret. Event names arrive uppercase-enum (DOCUMENT_SIGNED,DOCUMENT_COMPLETED…); the receiver also normalizes lowercase-dotted for forward-compat.handleDocumentCompletedis idempotent (early-return whenstatus='completed' && signedFileId) so 5xx retries don't double-write. Switch handles SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED + v2 aliases RECIPIENT_VIEWED/SIGNED. Detail:docs/documenso-integration-audit.md. - v1 vs v2 routing:
getPortDocumensoConfig(portId)resolves per-portapiVersion.documenso-client.tsexports version-aware wrappers (getDocument,createDocument,sendDocument,sendReminder,downloadSignedPdf,voidDocument,placeFields). v2 →/api/v2/envelope/*(multipart create,distributereturns per-recipient signingUrl,redistributefor reminders,field/create-manyfor bulk placement). v1 →/api/v1/documents/*. Template flow stays v1 (/api/v1/templates/{id}/generate-documentwith name-keyedformValues) — v2 instances accept via backcompat. v2-only settings honoured:documenso_signing_order(PARALLEL/SEQUENTIAL) +documenso_redirect_url. - Response normalization: 2.x uses
documentId/recipientId; v1.13 usesid.normalizeDocument()surfaces the legacyidform to downstream consumers. DOCUMENSO_API_URL: bare host only — never include/api/v1. Client appends versioned paths based onDOCUMENSO_API_VERSION. Double-pathing returns 404 with no useful diagnostic.
EOI generation
- Two pathways share
EoiContext(src/lib/services/eoi-context.ts). Documenso pathway usesdocumenso-payload.ts→ template-generate endpoint; in-app pathway fillsassets/eoi-template.pdfviasrc/lib/pdf/fill-eoi-form.ts. Routed throughgenerateAndSign(...)indocument-templates.tswith apathwayparameter. - Merge fields: Catalog in
src/lib/templates/merge-fields.ts;createTemplateSchemausesVALID_MERGE_TOKENSas an allow-list, rejecting unknown tokens at template creation. - Berth range formatter: Multi-berth EOIs render the in-bundle berth set as a compact range ("A1-A3, B5-B7") via
formatBerthRange()(src/lib/templates/berth-range.ts). Output populates the existingBerth NumberDocumenso field (single-berth = primary mooring verbatim; multi-berth = range). CRM UI always shows berths as chips.{{eoi.berthRange}}token available for template body copy. - Detail:
docs/eoi-documenso-field-mapping.md,assets/README.md.
UI patterns
- Sheet vs Drawer:
<Sheet side="right">(src/components/ui/sheet.tsx, Radix dialog) is the canonical side-panel for both desktop and mobile (w-3/4 sm:max-w-sm). Vaul<Drawer>(src/components/shared/drawer.tsx) is mobile-bottom-sheet only — currently justMoreSheet. Need a side panel? Use Sheet. Don't add Vaul without a mobile-bottom-sheet justification. - Inline editing: Detail pages use
<InlineEditableField>for text/select/textarea and<InlineTagEditor>for tag chips. Each entity exposesPUT /api/v1/<entity>/[id]/tagsbacked by aset<Entity>Tagsservice helper (single-transaction wipe-and-rewrite). No separate "Edit" modals — overview tab is editable in place. - Email + auth surfaces: Branded HTML in
src/lib/email/templates/; portal-auth usesportal-auth.ts. All templates: table-based, max-width 600, logo + blurred overhead background (s3.portnimara.com). CRM/login,/reset-password,/set-passwordand portal/portal/login,/portal/activate,/portal/reset-passwordall wrap content in<BrandedAuthShell>for visual continuity.
Document folders
- Per-port nestable tree (
document_folders.parent_idself-FK; null parent = root). Documents and files carry nullablefolder_id. Sibling-name uniqueness viauniq_document_folders_sibling_nameon(port_id, COALESCE(parent_id,'__root__'), LOWER(name)). Folder delete is soft rescue (deleteFolderSoftRescue) — re-parents children up, drops folder; never CASCADE.moveFolderwalks ancestor chain to prevent cycles. - Three system roots (
Clients/,Companies/,Yachts/) auto-created viaensureSystemRoots. Entity subfolders are lazy viaensureEntityFolder— race-safe via partial unique indexuniq_document_folders_entityon(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL. System rows mutated only by entity rename/archive/hard-delete (auto-sync via service helpers);assertNotSystemManagedrejects direct API mutation. - Auto-deposit on signing completion:
handleDocumentCompletedresolves owner via the Owner-wins chain (document.clientId ?? .companyId ?? .yachtId ?? interest.clientId), ensures the entity folder, and setsfiles.folder_id+ entity FK. Falls back to root when unresolvable. - Aggregated projection:
listFilesAggregatedByEntity/listInflightWorkflowsAggregatedByEntitywalk symmetric reach (Client ↔ Company viacompany_membershipsactive rows, ↔ Yacht viayachts.current_owner_type/id), group by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT), cap 20 per group. Defense-in-depthport_idat every join. File-FK snapshot is source of truth — historical files stay filed even if relationships change. - Permission gating:
documents.viewreads;documents.manage_foldersfor create/rename/move/delete (system folders immutable via API). - Deploy: migration
0051_documents_hub_split.sql+pnpm db:backfill:doc-folders(idempotent via per-port advisory lock).
Berths
- Public API:
/api/public/berths(list) +/api/public/berths/[mooringNumber](single) feed the marketing site. Output mirrors legacy NocoDB shape verbatim. Status precedence:"Sold">"Under Offer"(status OR activeis_specific_interest=truelink with open outcome) >"Available". Caches-maxage=300, stale-while-revalidate=60. - Public health:
/api/public/healthdual-mode — anonymous gets{status, timestamp}(never 503); requests with timing-safeX-Intake-SecretmatchingWEBSITE_INTAKE_SECRETget full{checks: {db, redis}}+ 503 on failure. The website uses the authenticated form on startup so it refuses to start when pointed at the wrong env. - Recommender: Pure SQL (no AI).
src/lib/services/berth-recommender.service.ts. Tier ladder A/B/C/D frominterest_berthsaggregates. Heat scoring fires only for tier B; weights tuned viasystem_settings(heat_weight_*,recommender_*,fallthrough_*,tier_ladder_hide_late_stage). Multi-port isolation enforced at entry point AND in the SQL aggregates CTE. - Rules engine:
src/lib/services/berth-rules-engine.ts. Seven triggers, all wired:eoi_sent,eoi_signed,deposit_received,contract_signed,interest_archived,interest_completed,berth_unlinked. Callers fireevaluateRule(...)via dynamic import (circular-dep avoidance). Defaults vary; admins tune viaberth_rulessetting. Pairs withadvanceStageIfBehindto keep pipeline stage in sync. - Per-berth PDFs: Versioned via
berth_pdf_versions;berths.current_pdf_version_idis current. Storage key is UUID per upload (no collisions on concurrent uploads);pg_advisory_xact_lockper berth_id serializes version-number allocation. 3-tier parse: AcroForm → OCR (Tesseract.js) → optional AI on low confidence. Magic-byte (%PDF-) check on BOTH in-server and presigned-PUT paths. Mooring mismatch → service-levelConflictErrorunlessconfirmMooringMismatch: true. - Brochures: Per-port,
is_defaultenforced by partial unique index(port_id) WHERE is_default=true AND archived_at IS NULL. Same upload flow as berth PDFs. - NocoDB re-import:
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara. Idempotent (skips rows whereupdated_at > last_imported_atunless--force); add--update-snapshotto rewrite the seed JSON. Helpers insrc/lib/services/berth-import.tsare unit-tested. - Plan-of-record:
docs/berth-recommender-and-pdf-plan.md.
Storage
- All file I/O through
getStorageBackend()(src/lib/storage/). Interface:put,get,head,delete,listByPrefix,presignUpload,presignDownload. Selected viasystem_settings.storage_backend('s3' | 'filesystem'). Switching backends = settings change +pnpm tsx scripts/migrate-storage.ts(round-trips every blob infiles,berth_pdf_versions,brochure_versions,gdpr_exports, verifies SHA-256). - MinIO calls wrapped in 30s
withTimeoutto prevent TCP-blackhole stalls. Filesystem backend is single-node only — refuses to start whenMULTI_NODE_DEPLOYMENT=true.
Send-from accounts (sales send-outs)
- Configurable via
system_settings; defaults tosales@portnimara.com(human) +noreply@portnimara.com(automation). SMTP/IMAP passwords AES-256-GCM at rest; API returns only*PassIsSetmarkers. - Audit →
document_sends(separate fromaudit_logsfor volume + binary refs). Body markdown rendered viarenderEmailBody()(escape-then-allowlist; XSS-tested). Rate limit 50 sends/user/hour. Files >email_attach_threshold_mbship as 24h signed-URL link (filename HTML-escaped against injection). The threshold banner in the compose UI is informational and shows whenever the preview API returns the per-port threshold — it does NOT depend on IMAP. Separately, bounce monitoring (imap-bounce-poller.ts) needs IMAP creds and no-ops cleanly when they're unset.
Pre-commit
Husky + lint-staged runs ESLint fix + Prettier on staged .ts/.tsx. Blocks all .env* files (including .env.example) — pass them via a separate workflow if needed.
Environment
Copy .env.example to .env. See src/lib/env.ts for the full Zod schema. SKIP_ENV_VALIDATION=1 bypasses validation (Docker build).
Dev/test-only env (not in .env.example):
EMAIL_REDIRECT_TO=<address>— reroutes every outbound email to this address, prefixes subject[redirected from <original>]. Dev safety net; must be unset in production.IMAP_HOST/IMAP_PORT/IMAP_USER/IMAP_PASS— used bytests/e2e/realapi/portal-imap-activation.spec.ts; the spec skips when any are missing.
Testing
Six Playwright projects (playwright.config.ts):
setup— global setup (seeds users, port, berths, system settings)smoke— fast click-through, run on every change (~10 min, 125 specs)exhaustive— deeper UI coveragedestructive— archive/delete/cancel paths against throwaway entitiesrealapi— opt-in real Documenso send-side + IMAP round-trip. NeedsDOCUMENSO_API_*,SMTP_*,IMAP_*env + cloudflared tunnel running for the local webhook receivervisual— pixel-diff baselines (tests/e2e/visual/snapshots.spec.ts-snapshots/); regenerate with--update-snapshots
Vitest covers unit + integration with mocked externals (tests/unit/, tests/integration/).
Docker
Dockerfile— production multi-stage (deps → build → runner)Dockerfile.dev— dev with bind-mounted sourceDockerfile.worker— BullMQ worker processdocker-compose.yml/.dev.yml/.prod.yml
Architecture docs
Numbered specs (01-CONSOLIDATED-SYSTEM-SPEC.md … 15-DESIGN-TOKENS.md) in repo root carry the detailed architecture decisions, schema docs, API catalog, and sequence.
Beta-phase tracker (read this first)
We are in pre-launch beta. docs/launch-readiness.md is the canonical
home for every outstanding initiative we need to ship before
production cutover. Read it at the start of any non-trivial task to
see what's in flight, what's blocked, and what's been deferred. Append
new launch-blocking items there (status tags: OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED) — do NOT create a new
parallel audit doc. Companion files:
docs/launch-readiness.md— the master pre-launch tracker (5+ initiatives: reports overhaul, marketing pipeline cutover, invoicing audit, codebase + security audit, website integration, e2e testing, data migration)docs/reports-content-spec.md— working spec for the reports initiative (per-category KPIs / charts / tables); referenced bylaunch-readiness.mdInitiative 1docs/superpowers/audits/active-uat.md— live UAT findings the user surfaces in chat; persists across sessions until explicit wrapdocs/BACKLOG.md— long-tail backlog index (post-launch and general)
Domain reference docs
docs/berth-recommender-and-pdf-plan.md— berths + PDF + send-outs bundledocs/eoi-documenso-field-mapping.md— canonical EoiContext ↔ Documenso/AcroForm mappingdocs/documenso-integration-audit.md— full Documenso v1/v2 quirks referenceassets/README.md— in-app EOI source PDF requirements