Files
pn-new-crm/CLAUDE.md
Matt cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
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>
2026-05-27 22:42:37 +02:00

21 KiB
Raw Blame History

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 Skill tool):
    • superpowers:brainstorming before any feature/component work — explores intent + design first
    • superpowers:test-driven-development for any feature or bugfix
    • superpowers:systematic-debugging for any bug / test failure / unexpected behavior
    • superpowers:verification-before-completion before claiming "done" or committing
    • superpowers:writing-plans / executing-plans for multi-step specs
    • superpowers:dispatching-parallel-agents when 2+ tasks are independent
    • frontend-design:frontend-design for new UI work (avoids generic AI aesthetics)
    • code-review:code-review and security-review before 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 call browser_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.
  • Agents (via Agent tool, subagent_type=):
    • Explore for any codebase search that would take > 3 queries
    • feature-dev:code-explorer / code-architect / code-reviewer for 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.md is 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.md is 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 to YYYY-MM-DD-uat.md and start a fresh active-uat.md. Buckets: Quick fixes (<15min), Medium (15min2h), 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 emit 204 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. Raw req.json() + schema.parse() produces a generic 500 instead of the field-level 400 the frontend's toastError hook expects.
  • Route handlers: route.ts files can only export GET|POST|…. Service-tested handlers live in sibling handlers.ts (e.g. src/app/api/v1/yachts/[id]/handlers.ts) and are imported by route.ts with withAuth(withPermission(...)). Integration tests import from handlers.ts directly to bypass middleware.

Data model

  • Polymorphic ownership: Yachts and invoice billing-entities use <entity>_type + <entity>_id pairs ('client' | 'company'). Resolve via src/lib/services/yachts.service.ts / eoi-context.ts — never read the columns ad hoc.
  • Multi-berth interest model: interest_berths is the source of truth — interests.berth_id does 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 via src/lib/services/interest-berths.service.ts helpers.
  • Notes (polymorphic): notes.service.ts dispatches across clientNotes/interestNotes/yachtNotes/companyNotes via an entityType discriminator. <NotesList entityType="…" /> works for all four. companyNotes lacks updatedAt — service substitutes createdAt for 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 via verifyDocumensoSecret. Event names arrive uppercase-enum (DOCUMENT_SIGNED, DOCUMENT_COMPLETED …); the receiver also normalizes lowercase-dotted for forward-compat. handleDocumentCompleted is idempotent (early-return when status='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-port apiVersion. documenso-client.ts exports version-aware wrappers (getDocument, createDocument, sendDocument, sendReminder, downloadSignedPdf, voidDocument, placeFields). v2 → /api/v2/envelope/* (multipart create, distribute returns per-recipient signingUrl, redistribute for reminders, field/create-many for bulk placement). v1 → /api/v1/documents/*. Template flow stays v1 (/api/v1/templates/{id}/generate-document with name-keyed formValues) — 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 uses id. normalizeDocument() surfaces the legacy id form to downstream consumers.
  • DOCUMENSO_API_URL: bare host only — never include /api/v1. Client appends versioned paths based on DOCUMENSO_API_VERSION. Double-pathing returns 404 with no useful diagnostic.

EOI generation

  • Two pathways share EoiContext (src/lib/services/eoi-context.ts). Documenso pathway uses documenso-payload.ts → template-generate endpoint; in-app pathway fills assets/eoi-template.pdf via src/lib/pdf/fill-eoi-form.ts. Routed through generateAndSign(...) in document-templates.ts with a pathway parameter.
  • Merge fields: Catalog in src/lib/templates/merge-fields.ts; createTemplateSchema uses VALID_MERGE_TOKENS as 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 existing Berth Number Documenso 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 just MoreSheet. 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 exposes PUT /api/v1/<entity>/[id]/tags backed by a set<Entity>Tags service 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 uses portal-auth.ts. All templates: table-based, max-width 600, logo + blurred overhead background (s3.portnimara.com). CRM /login, /reset-password, /set-password and portal /portal/login, /portal/activate, /portal/reset-password all wrap content in <BrandedAuthShell> for visual continuity.

Document folders

  • Per-port nestable tree (document_folders.parent_id self-FK; null parent = root). Documents and files carry nullable folder_id. Sibling-name uniqueness via uniq_document_folders_sibling_name on (port_id, COALESCE(parent_id,'__root__'), LOWER(name)). Folder delete is soft rescue (deleteFolderSoftRescue) — re-parents children up, drops folder; never CASCADE. moveFolder walks ancestor chain to prevent cycles.
  • Three system roots (Clients/, Companies/, Yachts/) auto-created via ensureSystemRoots. Entity subfolders are lazy via ensureEntityFolder — race-safe via partial unique index uniq_document_folders_entity on (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); assertNotSystemManaged rejects direct API mutation.
  • Auto-deposit on signing completion: handleDocumentCompleted resolves owner via the Owner-wins chain (document.clientId ?? .companyId ?? .yachtId ?? interest.clientId), ensures the entity folder, and sets files.folder_id + entity FK. Falls back to root when unresolvable.
  • Aggregated projection: listFilesAggregatedByEntity / listInflightWorkflowsAggregatedByEntity walk symmetric reach (Client ↔ Company via company_memberships active rows, ↔ Yacht via yachts.current_owner_type/id), group by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT), cap 20 per group. Defense-in-depth port_id at every join. File-FK snapshot is source of truth — historical files stay filed even if relationships change.
  • Permission gating: documents.view reads; documents.manage_folders for 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 active is_specific_interest=true link with open outcome) > "Available". Cache s-maxage=300, stale-while-revalidate=60.
  • Public health: /api/public/health dual-mode — anonymous gets {status, timestamp} (never 503); requests with timing-safe X-Intake-Secret matching WEBSITE_INTAKE_SECRET get 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 from interest_berths aggregates. Heat scoring fires only for tier B; weights tuned via system_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 fire evaluateRule(...) via dynamic import (circular-dep avoidance). Defaults vary; admins tune via berth_rules setting. Pairs with advanceStageIfBehind to keep pipeline stage in sync.
  • Per-berth PDFs: Versioned via berth_pdf_versions; berths.current_pdf_version_id is current. Storage key is UUID per upload (no collisions on concurrent uploads); pg_advisory_xact_lock per 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-level ConflictError unless confirmMooringMismatch: true.
  • Brochures: Per-port, is_default enforced 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 where updated_at > last_imported_at unless --force); add --update-snapshot to rewrite the seed JSON. Helpers in src/lib/services/berth-import.ts are 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 via system_settings.storage_backend ('s3' | 'filesystem'). Switching backends = settings change + pnpm tsx scripts/migrate-storage.ts (round-trips every blob in files, berth_pdf_versions, brochure_versions, gdpr_exports, verifies SHA-256).
  • MinIO calls wrapped in 30s withTimeout to prevent TCP-blackhole stalls. Filesystem backend is single-node only — refuses to start when MULTI_NODE_DEPLOYMENT=true.

Send-from accounts (sales send-outs)

  • Configurable via system_settings; defaults to sales@portnimara.com (human) + noreply@portnimara.com (automation). SMTP/IMAP passwords AES-256-GCM at rest; API returns only *PassIsSet markers.
  • Audit → document_sends (separate from audit_logs for volume + binary refs). Body markdown rendered via renderEmailBody() (escape-then-allowlist; XSS-tested). Rate limit 50 sends/user/hour. Files > email_attach_threshold_mb ship 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 by tests/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 coverage
  • destructive — archive/delete/cancel paths against throwaway entities
  • realapi — opt-in real Documenso send-side + IMAP round-trip. Needs DOCUMENSO_API_*, SMTP_*, IMAP_* env + cloudflared tunnel running for the local webhook receiver
  • visual — 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 source
  • Dockerfile.worker — BullMQ worker process
  • docker-compose.yml / .dev.yml / .prod.yml

Architecture docs

Numbered specs (01-CONSOLIDATED-SYSTEM-SPEC.md15-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 by launch-readiness.md Initiative 1
  • docs/superpowers/audits/active-uat.md — live UAT findings the user surfaces in chat; persists across sessions until explicit wrap
  • docs/BACKLOG.md — long-tail backlog index (post-launch and general)

Domain reference docs

  • docs/berth-recommender-and-pdf-plan.md — berths + PDF + send-outs bundle
  • docs/eoi-documenso-field-mapping.md — canonical EoiContext ↔ Documenso/AcroForm mapping
  • docs/documenso-integration-audit.md — full Documenso v1/v2 quirks reference
  • assets/README.md — in-app EOI source PDF requirements