Files
pn-new-crm/CLAUDE.md
Matt Ciaccio 0b8d08b57e docs(claude): add berth-recommender + storage + send-outs conventions
Phase 8: capture the new conventions established by the 19-commit
berth-recommender refactor so future Claude sessions don't re-litigate
the design decisions.

Added to the Conventions section:
- Multi-berth interest model + interest_berths role flags
- Mooring number canonical format
- Public berths API + health env-match
- Berth recommender (pure SQL, no AI; tier ladder; heat tunables)
- EOI bundle range formatter
- Pluggable storage backend (filesystem single-node-only constraint)
- Per-berth PDFs (UUID storage keys + advisory lock + 3-tier parser)
- Brochures (default-uniqueness via partial unique index)
- Send-from accounts (encrypted creds, *PassIsSet boolean, XSS guard,
  size-threshold link fallback, 50/hour rate limit)
- NocoDB berth import script

Updated Architecture docs section to note:
- The Documenso template needs the new "Berth Range" field added.
- Pointer to the comprehensive plan doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:09:27 +02:00

18 KiB
Raw Blame History

Port Nimara CRM

Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.

Quick reference

pnpm dev              # Start dev server
pnpm build            # Production build
pnpm lint             # ESLint
pnpm format           # Prettier
pnpm db:generate      # Generate Drizzle migrations
pnpm db:push          # Push schema to DB
pnpm db:studio        # Drizzle Studio GUI
pnpm db:seed          # Seed database (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

Tech stack

  • Framework: Next.js 15.1 App Router, output: 'standalone', experimental.typedRoutes
  • Auth: better-auth (session cookie: pn-crm.session_token)
  • Database: PostgreSQL via postgres driver + Drizzle ORM
  • Queue: BullMQ + Redis (ioredis)
  • Storage: MinIO (S3-compatible)
  • Realtime: Socket.IO with Redis adapter
  • UI: Radix UI primitives, shadcn/ui components (src/components/ui/), Lucide icons, CVA + tailwind-merge + clsx
  • Forms: react-hook-form + zod resolvers
  • Tables: TanStack Table
  • State: Zustand stores (src/stores/), TanStack React Query
  • PDF: pdfme
  • Email: nodemailer + imapflow + mailparser
  • AI: OpenAI SDK (optional)
  • Testing: Vitest (unit), Playwright (e2e)
  • Logging: pino + pino-pretty

Project structure

src/
  app/
    (auth)/          # Login/auth pages
    (dashboard)/     # Main app - route: /[portSlug]/...
    (portal)/        # Client portal
    api/             # API routes
  components/
    ui/              # shadcn/ui base components
    layout/          # Shell, sidebar, header
    [domain]/        # Domain components (clients, invoices, berths, etc.)
    shared/          # Cross-domain shared components
  hooks/             # React hooks (use-auth, use-permissions, use-socket, etc.)
  lib/
    api/             # API client utilities
    auth/            # better-auth config
    db/
      schema/        # Drizzle schema (one file per domain)
      migrations/    # Generated Drizzle migrations
    env.ts           # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
    services/        # Business logic services
    validators/      # Zod schemas for API input validation
    utils/           # Shared utilities
  middleware.ts      # Auth middleware (cookie check, redirects)
  providers/         # React context providers
  stores/            # Zustand stores
  types/             # Shared TypeScript types

Conventions

  • TypeScript: Strict mode with noUncheckedIndexedAccess. No any (ESLint error).
  • Formatting: Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
  • Lint: ESLint flat config extending next/core-web-vitals, next/typescript, prettier. Unused vars prefixed with _ are allowed.
  • Imports: Use @/* path alias (maps to src/*).
  • Components: shadcn/ui pattern - base components in src/components/ui/, domain components in src/components/[domain]/. Yacht / company / reservation domains live in components/yachts, components/companies, components/reservations respectively.
  • DB schema: One file per domain in src/lib/db/schema/, re-exported from index.ts. Relations in relations.ts. Domain files include clients.ts, yachts.ts, companies.ts, reservations.ts, interests.ts, berths.ts, documents.ts, invoices.ts, etc.
  • Polymorphic ownership: Yachts and invoice billing-entities use <entity>_type + <entity>_id column pairs ('client' | 'company'). Resolve owner identity through src/lib/services/yachts.service.ts / eoi-context.ts rather than reading the columns ad hoc — those services apply the type discriminator.
  • EOI generation: Two pathways share the same EoiContext (src/lib/services/eoi-context.ts). Documenso pathway calls the template-generate endpoint via documenso-payload.ts; in-app pathway fills the same source PDF (assets/eoi-template.pdf) via src/lib/pdf/fill-eoi-form.ts (pdf-lib AcroForm). Routed through generateAndSign(...) in src/lib/services/document-templates.ts with a pathway parameter.
  • Merge fields: Token catalog lives in src/lib/templates/merge-fields.ts; the createTemplateSchema validator uses VALID_MERGE_TOKENS as an allow-list, so unknown tokens are rejected at template creation time.
  • Documenso webhooks: Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the X-Documenso-Secret header — there is no HMAC. The receiver at src/app/api/webhooks/documenso/route.ts does a timing-safe equality check via verifyDocumensoSecret. Event names arrive as the uppercase Prisma enum on the wire (DOCUMENT_SIGNED, DOCUMENT_COMPLETED, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
  • Documenso API responses: 2.x renamed iddocumentId and recipient idrecipientId; v1.13 still uses id. src/lib/services/documenso-client.ts runs every response through normalizeDocument() which reads either field name and surfaces the legacy id form to downstream consumers.
  • Email templates: Branded HTML lives in src/lib/email/templates/. The portal-auth flow uses portal-auth.ts (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and width:100% for responsive shrink. The <img> URLs reference s3.portnimara.com directly (will move to /public later).
  • Portal auth pages: /portal/login, /portal/activate, /portal/reset-password and the CRM /login, /reset-password, /set-password all wrap their content in <BrandedAuthShell> (src/components/shared/branded-auth-shell.tsx) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
  • Inline editing pattern: detail pages (clients, yachts, companies, interests, residential clients/interests) use <InlineEditableField> (src/components/shared/inline-editable-field.tsx) for click-to-edit text/select/textarea fields and <InlineTagEditor> (src/components/shared/inline-tag-editor.tsx) for tag chips. Each entity exposes a PUT /api/v1/<entity>/[id]/tags endpoint backed by a set<Entity>Tags service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
  • Notes (polymorphic across entity types): notes.service.ts dispatches across clientNotes, interestNotes, yachtNotes, companyNotes based on an entityType discriminator. <NotesList entityType="…" /> works for all four. companyNotes lacks an updatedAt column — the service substitutes createdAt so callers get a uniform shape.
  • Route handler exports: Next.js App Router route.ts files only allow specific named exports (GET|POST|…). Service-tested handler functions live in sibling handlers.ts files (e.g. src/app/api/v1/yachts/[id]/handlers.ts) and are imported by the colocated route.ts for withAuth(withPermission(...)) wrapping. Integration tests import from handlers.ts directly to bypass auth/permission middleware.
  • Multi-berth interest model: interest_berths is the source of truth for which berths an interest is linked to; interests.berth_id does not exist (dropped in migration 0029). Three role flags: is_primary (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), is_specific_interest (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), is_in_eoi_bundle (covered by the interest's EOI signature). Read/write through src/lib/services/interest-berths.service.ts helpers (getPrimaryBerth, getPrimaryBerthsForInterests, upsertInterestBerth, setPrimaryBerth, removeInterestBerth); never query interest_berths from outside that service.
  • Mooring number canonical format: ^[A-Z]+\d+$ (e.g. A1, B12, E18) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public /api/public/berths/[mooringNumber] route before any DB hit.
  • Public berths API: /api/public/berths (list) and /api/public/berths/[mooringNumber] (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim ("Mooring Number", "Side Pontoon", etc.) — see src/lib/services/public-berths.ts. Cache headers: s-maxage=300, stale-while-revalidate=60. Status mapping: "Sold" (berth.status=sold) > "Under Offer" (status=under_offer OR has any active interest_berths.is_specific_interest=true link with interests.outcome IS NULL) > "Available". The companion /api/public/health endpoint returns {env, appUrl} so the website refuses to start when its CRM_PUBLIC_URL points at a different deployment env.
  • Berth recommender: Pure SQL ranking (no AI). Lives in src/lib/services/berth-recommender.service.ts. Tier ladder A/B/C/D classifies each feasible berth based on its interest_berths aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via system_settings keys (heat_weight_*, recommender_max_oversize_pct, recommender_top_n_default, fallthrough_policy, fallthrough_cooldown_days, tier_ladder_hide_late_stage). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth i.port_id filter).
  • EOI bundle / range formatter: Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via formatBerthRange() in src/lib/templates/berth-range.ts. Used only inside the Documenso Berth Range form field — CRM UI always shows berths as individual chips. The {{eoi.berthRange}} token is in VALID_MERGE_TOKENS.
  • Pluggable storage backend: Code never imports MinIO/S3 directly. All file I/O goes through getStorageBackend() from src/lib/storage/. Configured via system_settings.storage_backend ('s3' | 'filesystem'). Switching backends is a settings change + pnpm tsx scripts/migrate-storage.ts run. Filesystem backend is single-node only: refuses to start when MULTI_NODE_DEPLOYMENT=true. Multi-node deployments must use the s3-compatible backend.
  • Per-berth PDFs: Versioned via berth_pdf_versions; berths.current_pdf_version_id always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; pg_advisory_xact_lock per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (%PDF-) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level ConflictError unless the apply call passes confirmMooringMismatch: true.
  • Brochures: Per-port; default brochure marked via is_default (enforced by partial unique index on (port_id) WHERE is_default=true AND archived_at IS NULL). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).
  • Send-from accounts (sales send-outs): Configurable via system_settings; defaults to sales@portnimara.com for human-touch and noreply@portnimara.com for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only *PassIsSet boolean markers. Send-out audit goes to document_sends (separate from audit_logs because of volume + binary refs). Body markdown is XSS-safe via renderEmailBody() (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > email_attach_threshold_mb ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.
  • NocoDB berth import: pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara re-imports from the legacy NocoDB Berths table. Idempotent: rows where updated_at > last_imported_at (the "human edited this since last import" guard) are skipped unless --force. Adds --update-snapshot to also rewrite src/lib/db/seed-data/berths.json. Uses pg_advisory_xact_lock so two simultaneous runs serialize. Pure helpers in src/lib/services/berth-import.ts are unit-tested.
  • Routes: Multi-tenant via [portSlug] dynamic segment. Typed routes enabled.
  • Pre-commit: Husky + lint-staged runs ESLint fix + Prettier on staged .ts/.tsx files. The hook also blocks .env* files (including .env.example) from being committed; pass them via a separate workflow if needed.

Schema migrations during dev

When you run a db:push or apply a migration via psql against a running dev server, restart the dev server afterwards. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes column X does not exist errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with errorMissingColumn/42703 after a successful migration. Fix: kill next dev and restart it.

Environment

Copy .env.example to .env for local dev. See src/lib/env.ts for the full schema. Set SKIP_ENV_VALIDATION=1 to bypass validation (used in Docker build).

Optional dev/test-only env vars (not in .env.example):

  • EMAIL_REDIRECT_TO=<address> — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with [redirected from <original>]. Dev safety net so seeded fake-client emails don't escape; must be unset in production.
  • IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS — read by tests/e2e/realapi/portal-imap-activation.spec.ts to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.

Testing

Five Playwright projects, defined in playwright.config.ts:

  • setup — global setup (seeds users, port, berths, system settings).
  • smoke — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
  • exhaustive — deeper UI coverage that takes longer.
  • destructive — archive/delete/cancel paths against throwaway entities.
  • realapi — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires DOCUMENSO_API_*, SMTP_*, IMAP_* env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
  • visual — pixel-diff baselines for stable list/landing pages. Snapshots committed under tests/e2e/visual/snapshots.spec.ts-snapshots/. Regenerate with --update-snapshots after intentional UI changes.

Vitest covers unit + integration with mocked external services (tests/unit/, tests/integration/).

Docker

  • Dockerfile - Production multi-stage build (deps -> build -> runner)
  • Dockerfile.dev - Dev with bind-mounted source
  • Dockerfile.worker - BullMQ worker process
  • docker-compose.yml / docker-compose.dev.yml / docker-compose.prod.yml

Architecture docs

Numbered spec files in repo root (01-CONSOLIDATED-SYSTEM-SPEC.md through 15-DESIGN-TOKENS.md) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.

Domain-specific references:

  • docs/eoi-documenso-field-mapping.md — canonical mapping from EoiContext paths to the Documenso template's formValues keys, with the matching AcroForm field names used by the in-app pathway. Note: the multi- berth EOI bundle adds a new Berth Range form field populated by formatBerthRange() from src/lib/templates/berth-range.ts — the live Documenso template needs the field added before multi-berth EOIs render with the compact range string instead of just the primary mooring.
  • assets/README.md — what the in-app EOI source PDF must contain and how to override its path in dev/test.
  • docs/berth-recommender-and-pdf-plan.md — the comprehensive plan for the Phase 08 berth-recommender + PDF + send-outs work bundle. Single source of truth for the multi-berth interest model, recommender tier ladder, pluggable storage, per-berth PDF parser, and sales send-out flows.