Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 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
# 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.
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. - Deal-pulse risk signals:
dateDocumentDeclined,dateReservationCancelled,dateBerthSoldToOtherare NOT columns oninterests— they're derived at read time insidegetInterestByIdfromdocument_events(eventType in('rejected','declined')),berth_reservations(status='cancelled'), and other won interests sharing the same berth viainterest_berths. The Phase 2 design call: derive vs. denormalize — derivation kept the master plan's "no new tables" promise. Cost: 3 extra SELECTs on the detail endpoint (run in parallel); list views don't render the chip so they're unaffected. - 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). Bounce monitoring needs IMAP creds in addition to SMTP — without them, the size-rejection banner is disabled.
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.
Active plans of record under docs/:
docs/MASTER-PLAN-2026-05-18.md— current 7-phase post-audit plandocs/BACKLOG.md— single entry point for everything outstandingdocs/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