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>
18 KiB
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
postgresdriver + 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. Noany(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 tosrc/*). - Components: shadcn/ui pattern - base components in
src/components/ui/, domain components insrc/components/[domain]/. Yacht / company / reservation domains live incomponents/yachts,components/companies,components/reservationsrespectively. - DB schema: One file per domain in
src/lib/db/schema/, re-exported fromindex.ts. Relations inrelations.ts. Domain files includeclients.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>_idcolumn pairs ('client' | 'company'). Resolve owner identity throughsrc/lib/services/yachts.service.ts/eoi-context.tsrather 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 viadocumenso-payload.ts; in-app pathway fills the same source PDF (assets/eoi-template.pdf) viasrc/lib/pdf/fill-eoi-form.ts(pdf-lib AcroForm). Routed throughgenerateAndSign(...)insrc/lib/services/document-templates.tswith apathwayparameter. - Merge fields: Token catalog lives in
src/lib/templates/merge-fields.ts; thecreateTemplateSchemavalidator usesVALID_MERGE_TOKENSas 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-Secretheader — there is no HMAC. The receiver atsrc/app/api/webhooks/documenso/route.tsdoes a timing-safe equality check viaverifyDocumensoSecret. 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
id→documentIdand recipientid→recipientId; v1.13 still usesid.src/lib/services/documenso-client.tsruns every response throughnormalizeDocument()which reads either field name and surfaces the legacyidform to downstream consumers. - Email templates: Branded HTML lives in
src/lib/email/templates/. The portal-auth flow usesportal-auth.ts(activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px andwidth:100%for responsive shrink. The<img>URLs references3.portnimara.comdirectly (will move to/publiclater). - Portal auth pages:
/portal/login,/portal/activate,/portal/reset-passwordand the CRM/login,/reset-password,/set-passwordall 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 aPUT /api/v1/<entity>/[id]/tagsendpoint backed by aset<Entity>Tagsservice 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.tsdispatches acrossclientNotes,interestNotes,yachtNotes,companyNotesbased on anentityTypediscriminator.<NotesList entityType="…" />works for all four.companyNoteslacks anupdatedAtcolumn — the service substitutescreatedAtso callers get a uniform shape. - Route handler exports: Next.js App Router
route.tsfiles only allow specific named exports (GET|POST|…). Service-tested handler functions live in siblinghandlers.tsfiles (e.g.src/app/api/v1/yachts/[id]/handlers.ts) and are imported by the colocatedroute.tsforwithAuth(withPermission(...))wrapping. Integration tests import fromhandlers.tsdirectly to bypass auth/permission middleware. - Multi-berth interest model:
interest_berthsis the source of truth for which berths an interest is linked to;interests.berth_iddoes 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 throughsrc/lib/services/interest-berths.service.tshelpers (getPrimaryBerth,getPrimaryBerthsForInterests,upsertInterestBerth,setPrimaryBerth,removeInterestBerth); never queryinterest_berthsfrom 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.) — seesrc/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 activeinterest_berths.is_specific_interest=truelink withinterests.outcome IS NULL) >"Available". The companion/api/public/healthendpoint returns{env, appUrl}so the website refuses to start when itsCRM_PUBLIC_URLpoints 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 itsinterest_berthsaggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights viasystem_settingskeys (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-depthi.port_idfilter). - EOI bundle / range formatter: Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via
formatBerthRange()insrc/lib/templates/berth-range.ts. Used only inside the DocumensoBerth Rangeform field — CRM UI always shows berths as individual chips. The{{eoi.berthRange}}token is inVALID_MERGE_TOKENS. - Pluggable storage backend: Code never imports MinIO/S3 directly. All file I/O goes through
getStorageBackend()fromsrc/lib/storage/. Configured viasystem_settings.storage_backend('s3' | 'filesystem'). Switching backends is a settings change +pnpm tsx scripts/migrate-storage.tsrun. Filesystem backend is single-node only: refuses to start whenMULTI_NODE_DEPLOYMENT=true. Multi-node deployments must use the s3-compatible backend. - Per-berth PDFs: Versioned via
berth_pdf_versions;berths.current_pdf_version_idalways 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_lockper 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-levelConflictErrorunless the apply call passesconfirmMooringMismatch: 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 tosales@portnimara.comfor human-touch andnoreply@portnimara.comfor automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only*PassIsSetboolean markers. Send-out audit goes todocument_sends(separate fromaudit_logsbecause of volume + binary refs). Body markdown is XSS-safe viarenderEmailBody()(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_mbship 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-nimarare-imports from the legacy NocoDB Berths table. Idempotent: rows whereupdated_at > last_imported_at(the "human edited this since last import" guard) are skipped unless--force. Adds--update-snapshotto also rewritesrc/lib/db/seed-data/berths.json. Usespg_advisory_xact_lockso two simultaneous runs serialize. Pure helpers insrc/lib/services/berth-import.tsare unit-tested. - Routes: Multi-tenant via
[portSlug]dynamic segment. Typed routes enabled. - Pre-commit: Husky + lint-staged runs ESLint fix + Prettier on staged
.ts/.tsxfiles. 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 bytests/e2e/realapi/portal-imap-activation.spec.tsto 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). RequiresDOCUMENSO_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 undertests/e2e/visual/snapshots.spec.ts-snapshots/. Regenerate with--update-snapshotsafter 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 sourceDockerfile.worker- BullMQ worker processdocker-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 fromEoiContextpaths to the Documenso template'sformValueskeys, with the matching AcroForm field names used by the in-app pathway. Note: the multi- berth EOI bundle adds a newBerth Rangeform field populated byformatBerthRange()fromsrc/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 0–8 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.